From 9dd54cd976865c1c88564d6a2207da31e70f9f53 Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 18:31:38 -0400 Subject: [PATCH 01/32] update library syntax paths for VSCode --- .vscode/settings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 37a3b13..3a2609e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,11 @@ { "python.analysis.extraPaths": [ - "D:\\SteamLibrary\\steamapps\\common\\Blender\\4.3\\scripts\\addons", - "C:\\Users\\Onan\\AppData\\Roaming\\Blender Foundation\\Blender\\4.3\\extensions\\user_default\\",//C:/Users/Onan/AppData/Roaming/Blender Foundation/Blender/4.0/scripts/addons + "D:\\SteamLibrary\\steamapps\\common\\Blender\\4.4\\scripts\\addons", + "C:\\Users\\Onan\\AppData\\Roaming\\Blender Foundation\\Blender\\4.4\\extensions\\user_default\\",//C:/Users/Onan/AppData/Roaming/Blender Foundation/Blender/4.0/scripts/addons "D:\\blender stuff\\blendercodestuff\\4.3", - "D:\\SteamLibrary\\steamapps\\common\\Blender\\4.3\\python\\lib\\site-packages", - "/Users/frankche/Documents/blendercoding/4.1/", - "/Users/frankche/Library/Application Support/Blender/4.3/extensions/user_default/" + "D:\\SteamLibrary\\steamapps\\common\\Blender\\4.4\\python\\lib\\site-packages", + "/Users/frankche/Documents/blendercoding/4.3/", + "/Users/frankche/Library/Application Support/Blender/4.4/extensions/user_default/" ], "python.analysis.diagnosticSeverityOverrides": { "reportInvalidTypeForm": "none" From 3ada55006739c4c22739251166b098dd5c65e7ee Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 19:30:57 -0400 Subject: [PATCH 02/32] bug fixes --- core/resonite_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/resonite_utils.py b/core/resonite_utils.py index e5a210b..5312061 100644 --- a/core/resonite_utils.py +++ b/core/resonite_utils.py @@ -5,10 +5,10 @@ from numpy import double from typing import Set, Dict import re -from .common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker, identify_bones +from .common import get_active_armature, ProgressTracker, identify_bones from bpy.types import Context, Operator from ..core.translations import t -from ..core.dictionaries import bone_names, resonite_translations +from ..core.dictionaries import bone_names, resonite_translations, simplify_bonename from ..core.logging_setup import logger from ..core.armature_validation import validate_armature From e4d3f676a2ee5d87409a58e0a39dead51c4aaddd Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 19:55:10 -0400 Subject: [PATCH 03/32] make merge armature use the new identify bones method --- functions/custom_tools/armature_merging.py | 50 ++++++---------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index 133fe65..e9ddb25 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -2,7 +2,6 @@ import bpy import numpy as np from typing import List, Optional, Dict, Set, Tuple, Any from bpy.types import Context, Object, Operator, ArmatureModifier, EditBone, VertexGroup, Mesh, ShapeKey -from ...core.dictionaries import bone_names from ...core.logging_setup import logger from ...core.translations import t from ...core.common import ( @@ -11,6 +10,7 @@ from ...core.common import ( clear_unused_data_blocks, join_mesh_objects, remove_unused_shapekeys, + identify_bones, ) from ...core.dictionaries import simplify_bonename @@ -174,45 +174,23 @@ def merge_armatures( merge_armature_data: bpy.types.Armature = merge_armature.data for bone in merge_armature_data.bones: original_parents[bone.name] = bone.parent.name if bone.parent else None - - #create reverse lookup - reverse_bone_lookup = {} - for preferred_name, name_list in bone_names.items(): - for name in name_list: - reverse_bone_lookup[name] = preferred_name - - # Get base bone names - base_bone_names: Set[str] = {bone.name for bone in base_armature.data.bones} - - base_armature_standards: Dict[str,Optional[str]] = {} - for bone in base_bone_names: - if simplify_bonename(bone) in reverse_bone_lookup: - base_armature_standards[reverse_bone_lookup[simplify_bonename(bone)]] = bone - + # Switch to edit mode on merge armature and rename bones bpy.context.view_layer.objects.active = merge_armature bpy.ops.object.mode_set(mode='EDIT') - # Handle bone renaming/removing to target armature. - bone_names_source: list[str] = [bone.name for bone in merge_armature_data.edit_bones] - for bone in bone_names_source: - bone_name = bone - if bone_name not in base_bone_names: #not auto mergable to original - - if simplify_bonename(bone_name) in reverse_bone_lookup: #if is a standard bone through standard translation. - if reverse_bone_lookup[simplify_bonename(bone_name)] in base_armature_standards: #if this bone equals for example, "hips", does a bone that should be "hips" exist on our target armature? - #if so, rename this bone to that one - merge_armature_data.edit_bones[bone_name].name = base_armature_standards[reverse_bone_lookup[simplify_bonename(bone_name)]] - bone_name = merge_armature_data.edit_bones[bone_name].name - #adjust original parents list to point to the new name. - for child_bone in merge_armature_data.edit_bones[bone_name]: - original_parents[child_bone.name] = bone_name - #then remove so it doesn't clash when merged. - merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name]) - continue - - #if it really doesn't have a counter part, just don't bother. - else: + # Identify our bones to what their standard name is like "hips" for source and target armature bones. + identifed_base_bone_names: Dict[str,str] = identify_bones(base_armature.data) + identified_bone_names_source: Dict[str,str] = identify_bones(merge_armature_data) + + for standard,bone_name in identified_bone_names_source.items(): + if standard in identifed_base_bone_names: #if the bone we are at on our merge armature has a standard name translation for the target armature + merge_armature_data.edit_bones[bone_name].name = identifed_base_bone_names[standard] #change it's name to the one on the target merge to armature's coorisponding standard bone + bone_name = merge_armature_data.edit_bones[bone_name].name + #adjust original parents list to point to the new name. + for child_bone in merge_armature_data.edit_bones[bone_name]: + original_parents[child_bone.name] = bone_name + #then remove so it doesn't clash when merged. merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name]) # Return to object mode From 5cad28a41bb42bca13700f69eee89f2570b2b63d Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 20:35:59 -0400 Subject: [PATCH 04/32] fix pointers fix pointers in operators to point to class bl_idname property --- core/common.py | 14 ++++++++ functions/custom_tools/armature_merging.py | 17 ++-------- functions/tools/bone_tools.py | 19 ++++++++++- functions/tools/uv_tools.py | 2 ++ functions/visemes.py | 4 +-- ui/atlas_materials_panel.py | 8 ++--- ui/custom_avatar_panel.py | 14 ++++---- ui/optimization_panel.py | 13 +++++--- ui/quick_access_panel.py | 17 ++++++---- ui/settings_panel.py | 5 +-- ui/tools_panel.py | 37 ++++++++++++++-------- ui/uv_panel.py | 3 +- ui/uv_tools.py | 8 +++-- ui/visemes_panel.py | 5 +-- 14 files changed, 103 insertions(+), 63 deletions(-) diff --git a/core/common.py b/core/common.py index 4190571..f26dd6c 100644 --- a/core/common.py +++ b/core/common.py @@ -495,6 +495,20 @@ def fix_zero_length_bones(armature: Object) -> None: bone.length = 0.001 bpy.ops.object.mode_set(mode='OBJECT') +def remove_unused_vertex_groups(mesh: Object) -> None: + """Remove vertex groups with no weights""" + for vg in mesh.vertex_groups: + has_weights: bool = False + for vert in mesh.data.vertices: + for group in vert.groups: + if group.group == vg.index and group.weight > 0.001: + has_weights = True + break + if has_weights: + break + if not has_weights: + mesh.vertex_groups.remove(vg) + def calculate_bone_orientation(mesh: Object, vertices: List[Any]) -> Tuple[Vector, float]: """Calculate optimal bone orientation based on mesh geometry""" if not vertices: diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index e9ddb25..7cd0807 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -7,6 +7,7 @@ from ...core.translations import t from ...core.common import ( get_all_meshes, fix_zero_length_bones, + remove_unused_vertex_groups, clear_unused_data_blocks, join_mesh_objects, remove_unused_shapekeys, @@ -174,7 +175,7 @@ def merge_armatures( merge_armature_data: bpy.types.Armature = merge_armature.data for bone in merge_armature_data.bones: original_parents[bone.name] = bone.parent.name if bone.parent else None - + # Switch to edit mode on merge armature and rename bones bpy.context.view_layer.objects.active = merge_armature bpy.ops.object.mode_set(mode='EDIT') @@ -377,20 +378,6 @@ def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str) -> None: vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE') mesh.vertex_groups.remove(vg_from) -def remove_unused_vertex_groups(mesh: Object) -> None: - """Remove vertex groups with no weights""" - for vg in mesh.vertex_groups: - has_weights: bool = False - for vert in mesh.data.vertices: - for group in vert.groups: - if group.group == vg.index and group.weight > 0.001: - has_weights = True - break - if has_weights: - break - if not has_weights: - mesh.vertex_groups.remove(vg) - def apply_armature_to_mesh(armature: Object, mesh: Object) -> None: """Apply armature deformation to mesh""" armature_mod: ArmatureModifier = mesh.modifiers.new('PoseToRest', 'ARMATURE') diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 78e6c72..966fe84 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -7,7 +7,8 @@ from ...core.common import ( get_active_armature, get_all_meshes, ProgressTracker, - restore_bone_transforms + restore_bone_transforms, + remove_unused_vertex_groups, ) from ...core.armature_validation import validate_armature, validate_bone_hierarchy @@ -262,6 +263,22 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) return {'FINISHED'} +class AvatarToolKit_OT_RemoveZeroWeightVertexGroups(Operator): + """Operator to remove vertex groups with no weights""" + bl_idname = "avatar_toolkit.clean_vertex_groups" + bl_label = t("Tools.clean_vertex_groups") + bl_description = t("Tools.clean_vertex_groups_desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> set[str]: + meshes: list[bpy.types.Object] = get_all_meshes(context) + + for mesh_obj in meshes: + remove_unused_vertex_groups(mesh_obj) + + return {'FINISHED'} + + class AvatarToolKit_OT_RemoveSelectedBones(Operator): """Operator to remove selected bones from the zero weight bones list""" bl_idname = "avatar_toolkit.remove_selected_bones" diff --git a/functions/tools/uv_tools.py b/functions/tools/uv_tools.py index e7dcc5d..6002d73 100644 --- a/functions/tools/uv_tools.py +++ b/functions/tools/uv_tools.py @@ -31,6 +31,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): return False if not context.space_data: return False + if not hasattr(context.space_data, "show_uvedit"): + return False if not context.space_data.show_uvedit: return False if context.scene.tool_settings.use_uv_select_sync: diff --git a/functions/visemes.py b/functions/visemes.py index da332bc..889306a 100644 --- a/functions/visemes.py +++ b/functions/visemes.py @@ -124,7 +124,7 @@ class VisemePreview: cls._preview_shapes = None cls._mesh_name = "" -class ATOOLKIT_OT_preview_visemes(Operator): +class AvatarToolkit_OT_PreviewVisemes(Operator): """Operator for previewing viseme shapes in real-time""" bl_idname: str = "avatar_toolkit.preview_visemes" bl_label: str = t("Visemes.preview_label") @@ -181,7 +181,7 @@ def validate_deformation(mesh, mix_data): mesh_size = max(mesh.dimensions) return max_deform < (mesh_size * 0.4) -class ATOOLKIT_OT_create_visemes(Operator): +class AvatarToolkit_OT_CreateVisemes(Operator): """Operator for generating VRChat-compatible viseme shape keys""" bl_idname: str = "avatar_toolkit.create_visemes" bl_label: str = t("Visemes.create_label") diff --git a/ui/atlas_materials_panel.py b/ui/atlas_materials_panel.py index 8f8c056..35e6316 100644 --- a/ui/atlas_materials_panel.py +++ b/ui/atlas_materials_panel.py @@ -97,14 +97,14 @@ class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): row = layout.row(align=True) row.scale_y = 1.2 - row.operator("avatar_toolkit.select_all_materials", text="", icon='CHECKBOX_HLT', + row.operator(AvatarToolKit_OT_SelectAllMaterials.bl_idname, text="", icon='CHECKBOX_HLT', emboss=True).tooltip = t("TextureAtlas.select_all_tooltip") - row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT', + row.operator(AvatarToolKit_OT_SelectNoneMaterials.bl_idname, text="", icon='CHECKBOX_DEHLT', emboss=True).tooltip = t("TextureAtlas.select_none_tooltip") row.separator(factor=0.5) - row.operator("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN', + row.operator(AvatarToolKit_OT_ExpandAllMaterials.bl_idname, text="", icon='DISCLOSURE_TRI_DOWN', emboss=True).tooltip = t("TextureAtlas.expand_all_tooltip") - row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT', + row.operator(AvatarToolKit_OT_CollapseAllMaterials.bl_idname, text="", icon='DISCLOSURE_TRI_RIGHT', emboss=True).tooltip = t("TextureAtlas.collapse_all_tooltip") row.separator(factor=1.0) diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py index 3ebf160..451c88d 100644 --- a/ui/custom_avatar_panel.py +++ b/ui/custom_avatar_panel.py @@ -175,12 +175,12 @@ class AvatarToolKit_PT_CustomPanel(Panel): # Armature selection with better alignment row: UILayout = col.row(align=True) row.label(text=t('MergeArmature.into'), icon='ARMATURE_DATA') - row.operator("avatar_toolkit.search_merge_armature_into", + row.operator(AvatarToolkit_OT_SearchMergeArmatureInto.bl_idname, text=toolkit.merge_armature_into) row: UILayout = col.row(align=True) row.label(text=t('MergeArmature.from'), icon='ARMATURE_DATA') - row.operator("avatar_toolkit.search_merge_armature", + row.operator(AvatarToolkit_OT_SearchMergeArmature.bl_idname, text=toolkit.merge_armature) # Merge button with emphasis @@ -188,7 +188,7 @@ class AvatarToolKit_PT_CustomPanel(Panel): col: UILayout = merge_box.column(align=True) row: UILayout = col.row(align=True) row.scale_y = 1.5 - row.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA') + row.operator(AvatarToolkit_OT_MergeArmature.bl_idname, icon='ARMATURE_DATA') def draw_mesh_tools(self, layout: UILayout, context: Context) -> None: """Draw the mesh attachment tools section""" @@ -213,17 +213,17 @@ class AvatarToolKit_PT_CustomPanel(Panel): # Selection rows with icons and better alignment row: UILayout = col.row(align=True) row.label(text=t('CustomPanel.select_armature'), icon='ARMATURE_DATA') - row.operator("avatar_toolkit.search_merge_armature_into", + row.operator(AvatarToolkit_OT_SearchMergeArmatureInto.bl_idname, text=toolkit.merge_armature_into) row: UILayout = col.row(align=True) row.label(text=t('CustomPanel.select_mesh'), icon='MESH_DATA') - row.operator("avatar_toolkit.search_attach_mesh", + row.operator(AvatarToolkit_OT_SearchAttachMesh.bl_idname, text=toolkit.attach_mesh) row: UILayout = col.row(align=True) row.label(text=t('CustomPanel.select_bone'), icon='BONE_DATA') - row.operator("avatar_toolkit.search_attach_bone", + row.operator(AvatarToolkit_OT_SearchAttachBone.bl_idname, text=toolkit.attach_bone) # Attach button with emphasis @@ -231,4 +231,4 @@ class AvatarToolKit_PT_CustomPanel(Panel): col: UILayout = attach_box.column(align=True) row: UILayout = col.row(align=True) row.scale_y = 1.5 - row.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA') + row.operator(AvatarToolkit_OT_AttachMesh.bl_idname, icon='ARMATURE_DATA') diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py index 57e9802..04eb8dc 100644 --- a/ui/optimization_panel.py +++ b/ui/optimization_panel.py @@ -3,6 +3,9 @@ from typing import Set from bpy.types import Panel, Context, UILayout, Operator from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t +from ..functions.optimization.materials_tools import AvatarToolkit_OT_CombineMaterials +from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles,AvatarToolkit_OT_RemoveDoublesAdvanced +from ..functions.optimization.mesh_tools import AvatarToolkit_OT_JoinAllMeshes, AvatarToolkit_OT_JoinSelectedMeshes class AvatarToolKit_PT_OptimizationPanel(Panel): """Panel containing mesh and material optimization tools for avatar optimization""" @@ -26,7 +29,7 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): col.separator(factor=0.5) # Material Operations - col.operator("avatar_toolkit.combine_materials", icon='MATERIAL') + col.operator(AvatarToolkit_OT_CombineMaterials.bl_idname, icon='MATERIAL') # Mesh Cleanup Box cleanup_box: UILayout = layout.box() @@ -36,8 +39,8 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): # Remove Doubles Row row: UILayout = col.row(align=True) - row.operator("avatar_toolkit.remove_doubles", icon='MESH_DATA') - row.operator("avatar_toolkit.remove_doubles_advanced", icon='PREFERENCES') + row.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA') + row.operator(AvatarToolkit_OT_RemoveDoublesAdvanced.bl_idname, icon='PREFERENCES') # Join Meshes Box join_box: UILayout = layout.box() @@ -47,5 +50,5 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): # Join Meshes Row row: UILayout = col.row(align=True) - row.operator("avatar_toolkit.join_all_meshes", icon='OBJECT_DATA') - row.operator("avatar_toolkit.join_selected_meshes", icon='RESTRICT_SELECT_OFF') + row.operator(AvatarToolkit_OT_JoinAllMeshes.bl_idname, icon='OBJECT_DATA') + row.operator(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, icon='RESTRICT_SELECT_OFF') diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 59fbd6d..d0d6755 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -23,7 +23,10 @@ from ..functions.pose_mode import ( AvatarToolkit_OT_ApplyPoseAsShapekey, AvatarToolkit_OT_ApplyPoseAsRest ) -from ..core.armature_validation import validate_armature +from ..core.armature_validation import validate_armature, AvatarToolkit_OT_ValidateTPose +from ..core.importers.importer import AvatarToolKit_OT_Import +from ..core.resonite_utils import AvatarToolKit_OT_ExportResonite +from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature class AvatarToolKit_OT_ExportFBX(Operator): """Export selected objects as FBX""" @@ -41,8 +44,8 @@ class AvatarToolKit_MT_ExportMenu(Menu): def draw(self, context: Context) -> None: layout: UILayout = self.layout - layout.operator("avatar_toolkit.export_fbx", text=t("QuickAccess.export_fbx")) - layout.operator("avatar_toolkit.export_resonite", text=t("QuickAccess.export_resonite")) + layout.operator(AvatarToolKit_OT_ExportFBX.bl_idname, text=t("QuickAccess.export_fbx")) + layout.operator(AvatarToolKit_OT_ExportResonite.bl_idname, text=t("QuickAccess.export_resonite")) class AvatarToolKit_OT_ExportMenu(Operator): """Open the export menu""" @@ -170,7 +173,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): col = pose_box.column(align=True) col.label(text=t("Validation.tpose.label"), icon='ARMATURE_DATA') col.separator(factor=0.5) - col.operator("avatar_toolkit.validate_tpose", icon='CHECKMARK') + col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, icon='CHECKMARK') if props.show_tpose_validation: validation_box = col.box() @@ -207,7 +210,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Add standardize button standardize_box = info_box.box() - standardize_box.operator("avatar_toolkit.standardize_armature", + standardize_box.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, text=t("QuickAccess.standardize_armature"), icon='MODIFIER') @@ -247,5 +250,5 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Import/Export Buttons button_row: UILayout = col.row(align=True) button_row.scale_y = 1.5 - button_row.operator("avatar_toolkit.import", text=t("QuickAccess.import"), icon='IMPORT') - button_row.operator("avatar_toolkit.export", text=t("QuickAccess.export"), icon='EXPORT') + 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') diff --git a/ui/settings_panel.py b/ui/settings_panel.py index 6e7c322..0036ef6 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -10,6 +10,7 @@ from bpy.types import ( ) from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t, get_languages_list +from ..core.armature_validation import AvatarToolkit_OT_HighlightProblemBones, AvatarToolkit_OT_ClearBoneHighlighting class AvatarToolkit_OT_TranslationRestartPopup(Operator): """Popup dialog shown after language change to inform about restart requirement""" @@ -71,9 +72,9 @@ class AvatarToolKit_PT_SettingsPanel(Panel): col.separator() col.prop(props, "highlight_problem_bones") if props.highlight_problem_bones: - col.operator("avatar_toolkit.highlight_problem_bones", icon='COLOR') + col.operator(AvatarToolkit_OT_HighlightProblemBones.bl_idname, icon='COLOR') else: - col.operator("avatar_toolkit.clear_bone_highlighting", icon='X') + col.operator(AvatarToolkit_OT_ClearBoneHighlighting.bl_idname, icon='X') # Debug Settings debug_box = layout.box() diff --git a/ui/tools_panel.py b/ui/tools_panel.py index 7fbfe71..2d2b625 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -4,6 +4,15 @@ from bpy.types import Panel, Context, UILayout, Operator, UIList from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t +from ..core.resonite_utils import AvatarToolkit_OT_ConvertResonite +from ..functions.tools.mesh_separation import AvatarToolKit_OT_SeparateByLooseParts, AvatarToolKit_OT_SeparateByMaterials +from ..functions.tools.additional_tools import AvatarToolkit_OT_ApplyTransforms, AvatarToolkit_OT_CleanShapekeys +from ..functions.tools.bone_tools import AvatarToolKit_OT_CreateDigitigradeLegs, AvatarToolKit_OT_DeleteBoneConstraints, AvatarToolKit_OT_RemoveSelectedBones, AvatarToolKit_OT_RemoveZeroWeightBones, AvatarToolKit_OT_RemoveZeroWeightVertexGroups +from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature +from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones +from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity + + class AVATAR_TOOLKIT_UL_ZeroWeightBones(UIList): """UI List for displaying zero weight bones with selection options""" def draw_item(self, context, layout, data, item, icon, active_data, active_propname): @@ -37,7 +46,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col: UILayout = tools_box.column(align=True) col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS') col.separator(factor=0.5) - col.operator("avatar_toolkit.convert_resonite", text=t("Tools.convert_resonite"), icon='EXPORT') + col.operator(AvatarToolkit_OT_ConvertResonite.bl_idname, text=t("Tools.convert_resonite"), icon='EXPORT') # Separation Tools sep_box: UILayout = layout.box() @@ -45,22 +54,22 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE') col.separator(factor=0.5) row: UILayout = col.row(align=True) - row.operator("avatar_toolkit.separate_materials", text=t("Tools.separate_materials"), icon='MATERIAL') - row.operator("avatar_toolkit.separate_loose", text=t("Tools.separate_loose"), icon='MESH_DATA') + row.operator(AvatarToolKit_OT_SeparateByMaterials.bl_idname, text=t("Tools.separate_materials"), icon='MATERIAL') + row.operator(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, text=t("Tools.separate_loose"), icon='MESH_DATA') # Bone Tools bone_box: UILayout = layout.box() col = bone_box.column(align=True) col.label(text=t("Tools.bone_title"), icon='BONE_DATA') col.separator(factor=0.5) - col.operator("avatar_toolkit.create_digitigrade", text=t("Tools.create_digitigrade"), icon='BONE_DATA') + col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA') # Standardization Tools standardize_box: UILayout = bone_box.box() col = standardize_box.column(align=True) col.label(text=t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE') col.separator(factor=0.5) - col.operator("avatar_toolkit.standardize_armature", icon='CHECKMARK') + col.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, icon='CHECKMARK') # Weight Tools weight_box: UILayout = bone_box.box() @@ -78,12 +87,12 @@ class AvatarToolKit_PT_ToolsPanel(Panel): toolkit, "zero_weight_bones_index") col = box.column(align=True) - col.operator("avatar_toolkit.remove_selected_bones", + col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname, text=t("Tools.remove_selected_bones")) row = col.row(align=True) - row.operator("avatar_toolkit.clean_weights", text=t("Tools.clean_weights"), icon='GROUP_BONE') - row.operator("avatar_toolkit.clean_constraints", text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') + row.operator(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.clean_weights"), icon='GROUP_BONE') + row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') # Merge Tools merge_box: UILayout = layout.box() @@ -91,22 +100,22 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON') col.separator(factor=0.5) row = col.row(align=True) - row.operator("avatar_toolkit.merge_to_active", text=t("Tools.merge_to_active"), icon='BONE_DATA') - row.operator("avatar_toolkit.merge_to_parent", text=t("Tools.merge_to_parent"), icon='BONE_DATA') - col.operator("avatar_toolkit.connect_bones", text=t("Tools.connect_bones"), icon='BONE_DATA') + row.operator(AvatarToolkit_OT_MergeToActive.bl_idname, text=t("Tools.merge_to_active"), icon='BONE_DATA') + row.operator(AvatarToolkit_OT_MergeToParent.bl_idname, text=t("Tools.merge_to_parent"), icon='BONE_DATA') + col.operator(AvatarToolkit_OT_ConnectBones.bl_idname, text=t("Tools.connect_bones"), icon='BONE_DATA') # Additional Tools extra_box: UILayout = layout.box() col = extra_box.column(align=True) col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS') col.separator(factor=0.5) - col.operator("avatar_toolkit.apply_transforms", text=t("Tools.apply_transforms"), icon='OBJECT_DATA') - col.operator("avatar_toolkit.clean_shapekeys", text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA') + col.operator(AvatarToolkit_OT_ApplyTransforms.bl_idname, text=t("Tools.apply_transforms"), icon='OBJECT_DATA') + col.operator(AvatarToolkit_OT_CleanShapekeys.bl_idname, text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA') # Rigify Tools rigify_box: UILayout = layout.box() col = rigify_box.column(align=True) col.label(text=t("Tools.rigify_title"), icon='ARMATURE_DATA') col.separator(factor=0.5) - col.operator("avatar_toolkit.convert_rigify_to_unity", icon='ARMATURE_DATA') + col.operator(AvatarToolkit_OT_ConvertRigifyToUnity.bl_idname, icon='ARMATURE_DATA') col.prop(context.scene.avatar_toolkit, "merge_twist_bones") diff --git a/ui/uv_panel.py b/ui/uv_panel.py index d5499b6..b7d5596 100644 --- a/ui/uv_panel.py +++ b/ui/uv_panel.py @@ -1,6 +1,7 @@ import bpy from bpy.types import Panel, Context, UILayout from ..core.translations import t +from .main_panel import CATEGORY_NAME class AvatarToolKit_PT_UVPanel(Panel): """Main UV Tools panel for Avatar Toolkit""" @@ -8,7 +9,7 @@ class AvatarToolKit_PT_UVPanel(Panel): bl_idname = "OBJECT_PT_avatar_toolkit_uv_main" bl_space_type = 'IMAGE_EDITOR' bl_region_type = 'UI' - bl_category = "Avatar Toolkit" + bl_category = CATEGORY_NAME def draw(self, context: Context) -> None: layout: UILayout = self.layout diff --git a/ui/uv_tools.py b/ui/uv_tools.py index ecb258e..4bb60eb 100644 --- a/ui/uv_tools.py +++ b/ui/uv_tools.py @@ -1,6 +1,8 @@ import bpy from bpy.types import Panel, Context, UILayout from ..core.translations import t +from ..functions.tools.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget +from .uv_panel import AvatarToolKit_PT_UVPanel class AvatarToolKit_PT_UVTools(Panel): """UV Tools panel containing UV manipulation operators""" @@ -8,8 +10,8 @@ class AvatarToolKit_PT_UVTools(Panel): bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools" bl_space_type = 'IMAGE_EDITOR' bl_region_type = 'UI' - bl_category = "Avatar Toolkit" - bl_parent_id = "OBJECT_PT_avatar_toolkit_uv_main" + bl_category = "UV Tools" + bl_parent_id = AvatarToolKit_PT_UVPanel.bl_idname bl_order = 3 bl_options = {'DEFAULT_CLOSED'} @@ -22,6 +24,6 @@ class AvatarToolKit_PT_UVTools(Panel): col.separator(factor=0.5) row: UILayout = col.row(align=True) - row.operator("avatar_toolkit.align_uv_edges_to_target", + row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("UVTools.align_edges"), icon='GP_MULTIFRAME_EDITING') diff --git a/ui/visemes_panel.py b/ui/visemes_panel.py index 0ebb472..11d2a30 100644 --- a/ui/visemes_panel.py +++ b/ui/visemes_panel.py @@ -3,6 +3,7 @@ from bpy.types import Panel, Context, UILayout, Object, ShapeKey from ..core.translations import t from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.common import get_active_armature +from ..functions.visemes import AvatarToolkit_OT_PreviewVisemes, AvatarToolkit_OT_CreateVisemes class AvatarToolKit_PT_VisemesPanel(Panel): """Panel containing viseme creation and preview tools""" @@ -65,11 +66,11 @@ class AvatarToolKit_PT_VisemesPanel(Panel): col.separator() preview_text: str = t("Visemes.stop_preview") if props.viseme_preview_mode else t("Visemes.start_preview") - col.operator("avatar_toolkit.preview_visemes", text=preview_text, icon='HIDE_OFF') + col.operator(AvatarToolkit_OT_PreviewVisemes.bl_idname, text=preview_text, icon='HIDE_OFF') # Create Box create_box: UILayout = layout.box() col: UILayout = create_box.column(align=True) col.label(text=t("Visemes.create_label"), icon='ADD') col.separator(factor=0.5) - col.operator("avatar_toolkit.create_visemes", icon='ADD') + col.operator(AvatarToolkit_OT_CreateVisemes.bl_idname, icon='ADD') From 199551a5051ae67b773030335b6570fd9d1b6918 Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 20:45:32 -0400 Subject: [PATCH 05/32] add remove zero weight vertex groups to panel --- core/common.py | 6 +++++- functions/tools/bone_tools.py | 5 +++-- resources/translations/en_US.json | 3 +++ ui/tools_panel.py | 2 ++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/core/common.py b/core/common.py index f26dd6c..de56360 100644 --- a/core/common.py +++ b/core/common.py @@ -495,8 +495,9 @@ def fix_zero_length_bones(armature: Object) -> None: bone.length = 0.001 bpy.ops.object.mode_set(mode='OBJECT') -def remove_unused_vertex_groups(mesh: Object) -> None: +def remove_unused_vertex_groups(mesh: Object) -> int: """Remove vertex groups with no weights""" + removed: int = 0 for vg in mesh.vertex_groups: has_weights: bool = False for vert in mesh.data.vertices: @@ -508,6 +509,9 @@ def remove_unused_vertex_groups(mesh: Object) -> None: break if not has_weights: mesh.vertex_groups.remove(vg) + removed = removed+1 + + return removed def calculate_bone_orientation(mesh: Object, vertices: List[Any]) -> Tuple[Vector, float]: """Calculate optimal bone orientation based on mesh geometry""" diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 966fe84..ff420e9 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -272,10 +272,11 @@ class AvatarToolKit_OT_RemoveZeroWeightVertexGroups(Operator): def execute(self, context: Context) -> set[str]: meshes: list[bpy.types.Object] = get_all_meshes(context) - + removed: int = 0 for mesh_obj in meshes: - remove_unused_vertex_groups(mesh_obj) + removed = removed+remove_unused_vertex_groups(mesh_obj) + self.report({'INFO'}, t("Tools.vertex_groups_removed", count=removed)) return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 39b1183..80a6e93 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -191,6 +191,8 @@ "Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight", "Tools.clean_weights": "Remove Zero Weight Bones", "Tools.clean_weights_desc": "Remove bones with no vertex weights", + "Tools.clean_vertex_groups": "Remove Unused Vertex Groups", + "Tools.clean_vertex_groups_desc": "Remove vertex groups on meshes assigned to no vertices.", "Tools.preserve_parent_bones": "Preserve Parent Bones", "Tools.preserve_parent_bones_desc": "Keep bones that have children even if they have no weights", "Tools.target_bone_type": "Target Bone Type", @@ -204,6 +206,7 @@ "Tools.remove_selected_bones": "Remove Selected Bones", "Tools.remove_selected_bones_desc": "Remove selected zero weight bones from armature", "Tools.bones_removed": "Removed {count} bones", + "Tools.vertex_groups_removed": "Removed {count} vertex groups.", "Tools.clean_constraints": "Delete Bone Constraints", "Tools.clean_constraints_desc": "Remove all bone constraints from armature", "Tools.clean_constraints_success": "Removed {count} bone constraints", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index 2d2b625..a819ed3 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -93,6 +93,8 @@ class AvatarToolKit_PT_ToolsPanel(Panel): row = col.row(align=True) row.operator(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.clean_weights"), icon='GROUP_BONE') row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') + row = col.row(align=True) + row.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE') # Merge Tools merge_box: UILayout = layout.box() From f16105517e4d28ccbd3bba5bddb4f85c5963b395 Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 22:09:27 -0400 Subject: [PATCH 06/32] fix flip animation add to menu fix resonite animx importer bug add flip animations add flip animation keyframes to help users rekey and remake animations as if they were mirrored. --- core/common.py | 9 +- core/resonite_loader/resonite_animx.py | 1 - core/resonite_utils.py | 2 +- functions/tools/bone_tools.py | 110 ++++++++++++++++++++++++- resources/translations/en_US.json | 2 + ui/tools_panel.py | 37 +++++---- ui/uv_tools.py | 2 +- 7 files changed, 140 insertions(+), 23 deletions(-) diff --git a/core/common.py b/core/common.py index de56360..4e563d5 100644 --- a/core/common.py +++ b/core/common.py @@ -15,10 +15,9 @@ from bpy.types import (Context, Object, Modifier, EditBone, Operator, Material, from functools import lru_cache from bpy.props import PointerProperty, IntProperty, StringProperty from bpy.utils import register_class -from ..core.logging_setup import logger -from ..core.translations import t -from ..core.dictionaries import bone_names -from .dictionaries import reverse_bone_lookup, bone_names +from .logging_setup import logger +from .translations import t +from .dictionaries import reverse_bone_lookup, simplify_bonename class SceneMatClass(PropertyGroup): mat: PointerProperty(type=Material) @@ -383,7 +382,7 @@ def clear_unused_data_blocks() -> int: if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) return initial_count - final_count -def identify_bones(arm_data: bpy.types.Armature, context: bpy.types.Context) -> Dict[str,str]: +def identify_bones(arm_data: bpy.types.Armature) -> Dict[str,str]: """Identify bone names in an armature based on our reverse dictionary, so there is no confusion to what a bone is. Essentially makes a dictionary of keys from dictionaries.bone_names like "hips", and the corosponding value is the bone that can be mapped to that key.""" returned: Dict[str,str] = {} diff --git a/core/resonite_loader/resonite_animx.py b/core/resonite_loader/resonite_animx.py index ad6c4cf..4d15155 100644 --- a/core/resonite_loader/resonite_animx.py +++ b/core/resonite_loader/resonite_animx.py @@ -3,7 +3,6 @@ from os import replace from re import S from types import FrameType -import lz4.block from . import resonite_types from . import common diff --git a/core/resonite_utils.py b/core/resonite_utils.py index 5312061..02d56ed 100644 --- a/core/resonite_utils.py +++ b/core/resonite_utils.py @@ -77,7 +77,7 @@ class AvatarToolkit_OT_ConvertResonite(Operator): total_bones = len(arm_data.bones) with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress: - for key_simple,bone_name in identify_bones(arm_data,context).items(): + for key_simple,bone_name in identify_bones(arm_data).items(): if key_simple in resonite_translations: new_name = resonite_translations[key_simple] diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index ff420e9..9df48ce 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -9,6 +9,7 @@ from ...core.common import ( ProgressTracker, restore_bone_transforms, remove_unused_vertex_groups, + identify_bones, ) from ...core.armature_validation import validate_armature, validate_bone_hierarchy @@ -303,4 +304,111 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator): toolkit.zero_weight_bones.clear() self.report({'INFO'}, t("Tools.bones_removed", count=len(selected_bones))) - return {'FINISHED'} \ No newline at end of file + return {'FINISHED'} + + +class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): + """Operator to flip the selected bone keyframes using blender's flip pose.""" + bl_idname = "avatar_toolkit.flip_pose_frames" + bl_label = t("Tools.flip_pose_frames") + bl_description = t("Tools.flip_pose_frames_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + if context.mode != 'POSE': + return False + if not armature.animation_data: + return False + valid, _, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> set[str]: + armature = get_active_armature(context) + + + + armature_data: bpy.types.Armature = armature.data + + standard_mappings: Dict[str,str] = identify_bones(armature_data) + + + + + # Do we need this? If flipping in the future has issues, then uncommenting this may help - @989onan + #To make sure our flip pose is extremely reliable, we're gonna temp rename all bones to standard names to make the posing work. + #for standard,bone_name in standard_mappings.items(): + # armature_data.bones[bone_name].name = standard + + #save our selection + selected: list[bool] = [False] * len(armature_data.bones) + armature_data.bones.foreach_get("select", selected) + #select everything + armature_data.bones.foreach_set("select", [False] * len(armature_data.bones)) + + + + #create a set for every frame time where we need to key a keyframe for the flipped pose + times: Dict[float,list[bpy.types.FCurve]] = {} + for curve in armature.animation_data.action.fcurves: + if not curve.data_path.startswith("pose"): + continue + for point in curve.keyframe_points: + if point.select_control_point: + if point.co.x not in times: + times[point.co.x] = [] + + times[point.co.x].append(curve) + + for time,curves in times.items(): + context.scene.frame_set(frame=int(time), subframe=float(time-float(int(time)))) + armature_data.bones.foreach_set("select", [True] * len(armature_data.bones)) + bpy.ops.pose.copy() + armature_data.bones.foreach_set("select", [False] * len(armature_data.bones)) + bpy.ops.pose.paste(flipped=True,selected_mask=False) + + + + + for curve in curves: + + bone_name: str = curve.data_path.replace("pose.bones[\"","") + bone_name = bone_name[:bone_name.index("\"")] + + armature_data.bones[bone_name].select = True + + bpy.ops.pose.select_mirror(extend=False) + + #this can get the opposite side bone's data path and key it, if it is ever needed - @989onan + #for bone in armature_data.bones: + # if bone.select == True: + # bone_name = bone.name + # break + #new_path = curve.data_path[:curve.data_path.index("[")+1]+"\""+bone_name+"\""+curve.data_path[curve.data_path.index("]"):] + + if armature.keyframe_insert(data_path=curve.data_path, index=curve.array_index, frame=time): + #if armature.keyframe_insert(data_path=new_path, index=curve.array_index, frame=time): + continue + self.report({'ERROR'}, f"Keyframe insertion for key with data path \"{curve.data_path}\" and frame {time} failed!") + return {'FINISHED'} + + + + + + + + + + # Do we need this? If flipping in the future has issues, then uncommenting this may help - @989onan + #bring our names back as to not break their model. + #for standard,bone_name in standard_mappings.items(): + # armature_data.bones[standard].name = bone_name + + # restore selection + armature_data.bones.foreach_set("select", selected) + return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 80a6e93..fb0cc33 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -205,6 +205,8 @@ "Tools.zero_weight_bones_found": "Zero weight bones found: {bones}", "Tools.remove_selected_bones": "Remove Selected Bones", "Tools.remove_selected_bones_desc": "Remove selected zero weight bones from armature", + "Tools.flip_pose_frames": "Flip Selected Armature Key Frames", + "Tools.flip_pose_frames_desc": "Takes the selected keyframes and sets them to a mirrored pose, gotten from the opposite side of the armature on that frame.\nSelecting the entire animation's keyframes will flip the entire animation.", "Tools.bones_removed": "Removed {count} bones", "Tools.vertex_groups_removed": "Removed {count} vertex groups.", "Tools.clean_constraints": "Delete Bone Constraints", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index a819ed3..bc9f106 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -7,24 +7,18 @@ from ..core.translations import t from ..core.resonite_utils import AvatarToolkit_OT_ConvertResonite from ..functions.tools.mesh_separation import AvatarToolKit_OT_SeparateByLooseParts, AvatarToolKit_OT_SeparateByMaterials from ..functions.tools.additional_tools import AvatarToolkit_OT_ApplyTransforms, AvatarToolkit_OT_CleanShapekeys -from ..functions.tools.bone_tools import AvatarToolKit_OT_CreateDigitigradeLegs, AvatarToolKit_OT_DeleteBoneConstraints, AvatarToolKit_OT_RemoveSelectedBones, AvatarToolKit_OT_RemoveZeroWeightBones, AvatarToolKit_OT_RemoveZeroWeightVertexGroups +from ..functions.tools.bone_tools import ( + AvatarToolKit_OT_CreateDigitigradeLegs, + AvatarToolKit_OT_DeleteBoneConstraints, + AvatarToolKit_OT_RemoveSelectedBones, + AvatarToolKit_OT_RemoveZeroWeightBones, + AvatarToolKit_OT_RemoveZeroWeightVertexGroups, + AvatarToolKit_OT_FlipCurrentKeyFrames +) from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity - -class AVATAR_TOOLKIT_UL_ZeroWeightBones(UIList): - """UI List for displaying zero weight bones with selection options""" - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - if self.layout_type in {'DEFAULT', 'COMPACT'}: - row = layout.row(align=True) - row.prop(item, "selected", text="") - row.label(text=item.name) - if item.has_children: - row.label(text="", icon='OUTLINER_OB_ARMATURE') - if item.is_deform: - row.label(text="", icon='MOD_ARMATURE') - class AvatarToolKit_PT_ToolsPanel(Panel): """Panel containing various tools for avatar customization and optimization""" bl_label: str = t("Tools.label") @@ -63,6 +57,8 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.label(text=t("Tools.bone_title"), icon='BONE_DATA') col.separator(factor=0.5) col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA') + col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname,text=t("Tools.flip_pose_frames"),icon="ACTION") + # Standardization Tools standardize_box: UILayout = bone_box.box() @@ -121,3 +117,16 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.separator(factor=0.5) col.operator(AvatarToolkit_OT_ConvertRigifyToUnity.bl_idname, icon='ARMATURE_DATA') col.prop(context.scene.avatar_toolkit, "merge_twist_bones") + + +class AVATAR_TOOLKIT_UL_ZeroWeightBones(UIList): + """UI List for displaying zero weight bones with selection options""" + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if self.layout_type in {'DEFAULT', 'COMPACT'}: + row = layout.row(align=True) + row.prop(item, "selected", text="") + row.label(text=item.name) + if item.has_children: + row.label(text="", icon='OUTLINER_OB_ARMATURE') + if item.is_deform: + row.label(text="", icon='MOD_ARMATURE') \ No newline at end of file diff --git a/ui/uv_tools.py b/ui/uv_tools.py index 4bb60eb..d72e303 100644 --- a/ui/uv_tools.py +++ b/ui/uv_tools.py @@ -12,7 +12,7 @@ class AvatarToolKit_PT_UVTools(Panel): bl_region_type = 'UI' bl_category = "UV Tools" bl_parent_id = AvatarToolKit_PT_UVPanel.bl_idname - bl_order = 3 + bl_order = 0 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: From 71b22813a85d4f1a055e07ae4ff297d810c42199 Mon Sep 17 00:00:00 2001 From: 989onan Date: Wed, 2 Apr 2025 23:18:09 -0400 Subject: [PATCH 07/32] added request #112 added request #112 --- functions/tools/general_mesh_tools.py | 101 ++++++++++++++++++++++++++ resources/translations/en_US.json | 3 + ui/tools_panel.py | 8 ++ 3 files changed, 112 insertions(+) create mode 100644 functions/tools/general_mesh_tools.py diff --git a/functions/tools/general_mesh_tools.py b/functions/tools/general_mesh_tools.py new file mode 100644 index 0000000..0ac6d3c --- /dev/null +++ b/functions/tools/general_mesh_tools.py @@ -0,0 +1,101 @@ +import bpy +import numpy as np +from bpy.types import Operator, Context +from typing import Set +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, get_all_meshes +from ...core.armature_validation import validate_armature + +import bmesh + + +class MapItem(): + length: int + current_node: bmesh.types.BMVert + marched_paths: list[bmesh.types.BMEdge] + +class AvatarToolkit_OT_SelectShortestSeamPath(Operator): + """Find the shortest seam path between two vertices.""" + bl_idname = "avatar_toolkit.find_shortest_seam_path" + bl_label = t("Tools.find_shortest_seam_path") + bl_description = t("Tools.find_shortest_seam_path_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + if context.mode != "EDIT_MESH": + return False + mesh_data: bpy.types.Mesh = context.active_object.data + mesh = bmesh.from_edit_mesh(mesh_data) + selected: int = 0 + for vert in mesh.verts: + if vert.select == True: + selected = selected+1 + if selected > 2: + return False + found_seam: bool = False + for edge in vert.link_edges: + if edge.seam: + found_seam = True + if not found_seam: + return False + if selected < 2: + return False + armature = get_active_armature(context) + if not armature: + return False + valid, _, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + mesh_data: bpy.types.Mesh = context.active_object.data + mesh = bmesh.from_edit_mesh(mesh_data) + vert1: bmesh.types.BMVert = None + vert2: bmesh.types.BMVert = None + for vert in mesh.verts: + if vert.select == True: + if vert1 == None: + vert1 = vert + else: + vert2 = vert + + current_verts: list[MapItem] = [] + + first_item: MapItem = MapItem() + first_item.current_node = vert1 + first_item.length = 0 + first_item.marched_paths = [] + current_verts.append(first_item) + + def find_next_edge() -> list[bmesh.types.BMEdge]: + if len(current_verts) == 0: #all paths have been exausted. + return [] + for mapeditem in current_verts: + current_verts.remove(mapeditem) + for edge in mapeditem.current_node.link_edges: + if edge.seam and (edge not in mapeditem.marched_paths): + for vert_new in edge.verts: + if vert_new != mapeditem.current_node: + if vert_new == vert2: + mapeditem.marched_paths.append(edge) + return mapeditem.marched_paths + first_item: MapItem = MapItem() + first_item.current_node = vert_new + first_item.length = mapeditem.length+1 + first_item.marched_paths = [] + first_item.marched_paths.extend(mapeditem.marched_paths) + first_item.marched_paths.append(edge) + current_verts.append(first_item) + return find_next_edge() + + mesh.select_flush(False) + path: list[bmesh.types.BMEdge] = find_next_edge() + for edge in path: + edge.select = True + for vert in edge.verts: + vert.select = True + bpy.ops.mesh.select_mode(type='EDGE') + + return {'FINISHED'} + diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index fb0cc33..46771cd 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -167,6 +167,7 @@ "Optimization.remove_doubles_completed": "Remove doubles completed successfully", "Tools.label": "Tools", + "Tools.mesh_title": "Mesh Tools", "Tools.general_title": "General Tools", "Tools.select_armature": "Select an Armature", "Tools.convert_resonite": "Convert to Resonite", @@ -216,6 +217,8 @@ "Tools.clean_weights_success": "Removed {count} zero-weight bones", "Tools.clean_weights_threshold": "Weight Threshold", "Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted", + "Tools.find_shortest_seam_path": "Find Shortest Seam Path", + "Tools.find_shortest_seam_path_desc": "Find shortest path of seams between two selected vertices connected to seams.", "Tools.merge_title": "Merge Tools", "Tools.merge_to_active": "Merge to Active", "Tools.merge_to_active_desc": "Merge selected bones to active bone", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index bc9f106..901bc9c 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -18,6 +18,7 @@ from ..functions.tools.bone_tools import ( from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity +from ..functions.tools.general_mesh_tools import AvatarToolkit_OT_SelectShortestSeamPath class AvatarToolKit_PT_ToolsPanel(Panel): """Panel containing various tools for avatar customization and optimization""" @@ -59,6 +60,13 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA') col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname,text=t("Tools.flip_pose_frames"),icon="ACTION") + # Mesh Tools + mesh_box: UILayout = layout.box() + col = mesh_box.column(align=True) + col.label(text=t("Tools.mesh_title"), icon='MESH_DATA') + col.separator(factor=0.5) + col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname,text=t("Tools.find_shortest_seam_path"),icon="MESH_DATA") + # Standardization Tools standardize_box: UILayout = bone_box.box() From f28e1866a9655e6afb93073df1347299141bc295 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 3 Apr 2025 02:45:39 -0400 Subject: [PATCH 08/32] Add AMFOWSH Add apply modifier for object with shapekeys tool --- core/common.py | 12 ++ .../custom_tools/force_apply_modifier.py | 139 ++++++++++++++++++ resources/translations/en_US.json | 2 + ui/tools_panel.py | 2 + 4 files changed, 155 insertions(+) create mode 100644 functions/custom_tools/force_apply_modifier.py diff --git a/core/common.py b/core/common.py index 4e563d5..e0093e9 100644 --- a/core/common.py +++ b/core/common.py @@ -535,6 +535,18 @@ def add_armature_modifier(mesh: Object, armature: Object) -> None: modifier: Modifier = mesh.modifiers.new('Armature', 'ARMATURE') modifier.object = armature +def get_modifiers(self: Optional[Any] = None, context: Optional[Context] = None) -> List[Tuple[str, str, str]]: + returned: List[Tuple[str, str, str]] = [] + if context.active_object == None: + return returned + if context.active_object.type != "MESH": + return returned + for mod in context.active_object.modifiers: + returned.append((mod.name,mod.name,"")) + + return returned + + def get_shapekeys(context: Context, names: List[str], is_mouth: bool, diff --git a/functions/custom_tools/force_apply_modifier.py b/functions/custom_tools/force_apply_modifier.py new file mode 100644 index 0000000..2d478ab --- /dev/null +++ b/functions/custom_tools/force_apply_modifier.py @@ -0,0 +1,139 @@ +import traceback +import bpy +import re +from typing import Any, Set, Dict, List, Optional, Tuple +from bpy.types import ( + Operator, + Context, + Object, + Material, + NodeTree, + ShaderNodeTexImage +) +import mathutils +import bmesh +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + ProgressTracker, + calculate_bone_orientation, + add_armature_modifier, + get_modifiers, + has_shapekeys +) +from ...core.armature_validation import validate_armature + +class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): + """Operator for forcing the application of a modifier. A shortened way of saying \"Apply modifier for object with shapekeys\"""" + bl_idname: str = 'avatar_toolkit.merge_armatures' + bl_label: str = t('Tools.apply_modifier_on_shapekey_obj') + bl_description: str = t('Tools.apply_modifier_on_shapekey_obj_desc') + bl_options: Set[str] = {'REGISTER', 'UNDO'} + + modifier: bpy.props.EnumProperty(items=get_modifiers,name="Modifier To Apply") + + + def draw(self, context: Context) -> None: + """Draw the operator's UI""" + layout = self.layout + layout.prop(self, "modifier") + + def invoke(self, context: Context, event: bpy.types.Event) -> set[str]: + """Initialize the operator""" + return context.window_manager.invoke_props_dialog(self) + + @classmethod + def poll(cls, context: Context) -> bool: + if context.active_object != None: + return context.active_object.type == "MESH" + return False + + def execute(self, context: Context) -> Set[str]: + + obj: bpy.types.Object = context.active_object + mesh: bpy.types.Mesh = obj.data + + shapes: list[bpy.types.Object] = [] + + bpy.ops.object.mode_set(mode="OBJECT") + + if has_shapekeys(obj): + #reset shapekeys + for idx,key in enumerate(mesh.shape_keys.key_blocks): + obj.active_shape_key_index = idx + obj.active_shape_key.value = 0 + + for idx,key in enumerate(mesh.shape_keys.key_blocks): + # duplicate object for shapekey + bpy.ops.object.select_all(action="DESELECT") + context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.duplicate() + + # name new object after shapekey + new_obj = context.view_layer.objects.active + new_obj.select_set(True) + new_obj.active_shape_key_index = idx + new_obj.name = new_obj.active_shape_key.name + + #add to cleanup list + shapes.append(new_obj) + + #make basis the same shape as shapekey + for idx,point in enumerate(new_obj.active_shape_key.points): + new_obj.data.vertices[idx].co.xyz = point.co.xyz + + #remove all shaoekeys on new object and then apply modifier + bpy.ops.object.shape_key_remove(all=True,apply_mix=False) + try: + bpy.ops.object.modifier_apply(modifier=self.modifier) + except Exception as e: + self.report({'ERROR'}, f"Shapekey modifier apply for shapekey \"{new_obj.name}\" failed!!") + print(f"Shapekey modifier apply for shapekey \"{new_obj.name}\" failed!!") + print(traceback.format_exc(e)) + #clean up after critical failure + for shape in shapes: + bpy.data.objects.remove(shape)#faster than ops delete + bpy.ops.object.select_all(action="DESELECT") + + + + try: + #remove shapekeys on original object + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + bpy.ops.object.shape_key_remove(all=True,apply_mix=False) + bpy.ops.object.modifier_apply(modifier=self.modifier) + bpy.ops.object.select_all(action="DESELECT") + #delete first shapekey object aka basis + bpy.data.objects.remove(shapes.pop(0)) + + #join all objects with applied modifiers back together as shapes + for shape in shapes: + shape.select_set(True) + obj.select_set(True) + context.view_layer.objects.active = obj + bpy.ops.object.join_shapes() + except Exception as e: + + self.report({'ERROR'}, f"Shapekey joining failed!!") + print(f"Shapekey joining failed!!") + print(traceback.format_exc(e)) + #clean up after critical failure + for shape in shapes: + bpy.data.objects.remove(shape)#faster than ops delete + + #final clean up + for shape in shapes: + bpy.data.objects.remove(shape)#faster than ops delete + + else: + #mesh has no shapekeys, just apply normally. + bpy.ops.object.modifier_apply(modifier=self.modifier) + + + + return {'FINISHED'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 46771cd..2c642d6 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -219,6 +219,8 @@ "Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted", "Tools.find_shortest_seam_path": "Find Shortest Seam Path", "Tools.find_shortest_seam_path_desc": "Find shortest path of seams between two selected vertices connected to seams.", + "Tools.apply_modifier_on_shapekey_obj":"Apply Modifier on Shapekey Object", + "Tools.apply_modifier_on_shapekey_obj_desc":"Applies a modifier on an object regardless of it having shapekeys.", "Tools.merge_title": "Merge Tools", "Tools.merge_to_active": "Merge to Active", "Tools.merge_to_active_desc": "Merge selected bones to active bone", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index 901bc9c..b8aa933 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -19,6 +19,7 @@ from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeA from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity from ..functions.tools.general_mesh_tools import AvatarToolkit_OT_SelectShortestSeamPath +from ..functions.custom_tools.force_apply_modifier import AvatarToolkit_OT_ApplyModifierForShapkeyObj class AvatarToolKit_PT_ToolsPanel(Panel): """Panel containing various tools for avatar customization and optimization""" @@ -66,6 +67,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.label(text=t("Tools.mesh_title"), icon='MESH_DATA') col.separator(factor=0.5) col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname,text=t("Tools.find_shortest_seam_path"),icon="MESH_DATA") + col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname,text=t("Tools.apply_modifier_on_shapekey_obj"),icon="SHAPEKEY_DATA") # Standardization Tools From 3414ad89178bd6c2667c484795087b8a594f8325 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 3 Apr 2025 15:39:03 +0100 Subject: [PATCH 09/32] Initial MMD Importer Commit - This is the initial commit I spent several hours trying to get it up two Avatar Toolkit standard, it does not work yet because there are files missing but I been doing this since 6am and it is 4pm almost, i need food. - I have also removed as much legacy code as i could, MMD Tools contains so much of it even though there have a 4.2+ only version there have not removed any of the legacy code for pre 4.2.... this is going to take a while. God I hope this works fine once I am done. --- core/importers/pmx/__ini__.py | 0 core/importers/pmx/importer.py | 1075 ++++++++++++++++++++++++++++++ core/mmd/__init__.py | 0 core/mmd/bone.py | 587 ++++++++++++++++ core/mmd/core/bpyutils.py | 533 +++++++++++++++ core/mmd/core/utils.py | 296 ++++++++ core/mmd/material.py | 697 +++++++++++++++++++ core/mmd/properties/__init__.py | 0 core/mmd/properties/pose_bone.py | 250 +++++++ core/mmd/properties/root.py | 582 ++++++++++++++++ 10 files changed, 4020 insertions(+) create mode 100644 core/importers/pmx/__ini__.py create mode 100644 core/importers/pmx/importer.py create mode 100644 core/mmd/__init__.py create mode 100644 core/mmd/bone.py create mode 100644 core/mmd/core/bpyutils.py create mode 100644 core/mmd/core/utils.py create mode 100644 core/mmd/material.py create mode 100644 core/mmd/properties/__init__.py create mode 100644 core/mmd/properties/pose_bone.py create mode 100644 core/mmd/properties/root.py diff --git a/core/importers/pmx/__ini__.py b/core/importers/pmx/__ini__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/importers/pmx/importer.py b/core/importers/pmx/importer.py new file mode 100644 index 0000000..3f0a1f2 --- /dev/null +++ b/core/importers/pmx/importer.py @@ -0,0 +1,1075 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import bpy +import collections +import os +import time +import typing +from typing import TYPE_CHECKING, List, Optional, Dict, Set, Tuple, Any, Union +from mathutils import Matrix, Vector + +from bpy.types import Context, Object + +from ...logging_setup import logger +from ...common import ProgressTracker +from ...translations import t + +from ...mmd.core import bpyutils, utils +from ...mmd.core.bpyutils import FnContext +from .. import pmx +from ..bone import FnBone +from ..material import FnMaterial +from ..model import FnModel, Model +from ..morph import FnMorph +from ..rigid_body import FnRigidBody +from ..vmd.importer import BoneConverter +from ...operators.misc import MoveObject + +if TYPE_CHECKING: + from ...mmd.properties.pose_bone import MMDBone + from ...mmd.properties.root import MMDRoot + + +class PMXImporter: + """PMX model importer for Avatar Toolkit""" + + CATEGORIES = { + 0: "SYSTEM", + 1: "EYEBROW", + 2: "EYE", + 3: "MOUTH", + } + + MORPH_TYPES = { + 0: "group_morphs", + 1: "vertex_morphs", + 2: "bone_morphs", + 3: "uv_morphs", + 4: "uv_morphs", + 5: "uv_morphs", + 6: "uv_morphs", + 7: "uv_morphs", + 8: "material_morphs", + } + + def __init__(self): + self.__model = None + self.__targetContext = FnContext.ensure_context() + + self.__scale = None + + self.__root: Optional[bpy.types.Object] = None + self.__armObj: Optional[bpy.types.Object] = None + self.__meshObj: Optional[bpy.types.Object] = None + + self.__vertexGroupTable = None + self.__textureTable = None + self.__rigidTable = None + + self.__boneTable = [] + self.__materialTable = [] + self.__imageTable = {} + + self.__sdefVertices = {} # pmx vertices + self.__blender_ik_links = set() + self.__vertex_map = None + + self.__materialFaceCountTable = None + + @staticmethod + 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: Tuple[float, float]) -> Tuple[float, float]: + """Flip the V coordinate of UV mapping""" + u, v = uv + return u, 1.0 - v + + def __createObjects(self) -> None: + """Create main objects and link them to scene.""" + 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) + root = self.__rig.rootObject() + mmd_root: MMDRoot = root.mmd_root + self.__root = root + self.__armObj = self.__rig.armature() + + root["import_folder"] = os.path.dirname(pmxModel.filepath) + + txt = bpy.data.texts.new(obj_name) + txt.from_string(pmxModel.comment.replace("\r", "")) + mmd_root.comment_text = txt.name + txt = bpy.data.texts.new(obj_name + "_e") + txt.from_string(pmxModel.comment_e.replace("\r", "")) + mmd_root.comment_e_text = txt.name + + def __createMeshObject(self) -> None: + """Create a mesh object for the model""" + model_name = self.__root.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) + + def __createBasisShapeKey(self) -> None: + """Create a basis shape key if it doesn't exist""" + if self.__meshObj.data.shape_keys: + assert len(self.__meshObj.data.vertices) > 0 + assert len(self.__meshObj.data.shape_keys.key_blocks) > 1 + return + FnContext.set_active_object(self.__targetContext, self.__meshObj) + bpy.ops.object.shape_key_add() + + def __importVertexGroup(self) -> None: + """Import vertex groups from bones""" + vgroups = self.__meshObj.vertex_groups + self.__vertexGroupTable = [vgroups.new(name=i.name) for i in self.__model.bones] or [vgroups.new(name="NO BONES")] + + def __importVertices(self) -> None: + """Import vertices with weights and other properties""" + self.__importVertexGroup() + + pmxModel = self.__model + pmx_vertices = pmxModel.vertices + vertex_count = len(pmx_vertices) + vertex_map = self.__vertex_map + if vertex_map: + indices = collections.OrderedDict(vertex_map).keys() + pmx_vertices = tuple(pmxModel.vertices[x] for x in indices) + vertex_count = len(indices) + if vertex_count < 1: + return + + mesh: bpy.types.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))) + + vertex_group_table = self.__vertexGroupTable + vg_edge_scale = self.__meshObj.vertex_groups.new(name="mmd_edge_scale") + vg_vertex_order = self.__meshObj.vertex_groups.new(name="mmd_vertex_order") + for i, pv in enumerate(pmx_vertices): + pv_bones, pv_weights, idx = pv.weight.bones, pv.weight.weights, (i,) + + vg_edge_scale.add(index=idx, weight=pv.edge_scale, type="REPLACE") + vg_vertex_order.add(index=idx, weight=i / vertex_count, type="REPLACE") + + if isinstance(pv_weights, pmx.BoneWeightSDEF): + if pv_bones[0] > pv_bones[1]: + pv_bones.reverse() + pv_weights.weight = 1.0 - pv_weights.weight + pv_weights.r0, pv_weights.r1 = pv_weights.r1, pv_weights.r0 + vertex_group_table[pv_bones[0]].add(index=idx, weight=pv_weights.weight, type="ADD") + vertex_group_table[pv_bones[1]].add(index=idx, weight=1.0 - pv_weights.weight, type="ADD") + self.__sdefVertices[i] = pv + elif len(pv_bones) == 1: + bone_index = pv_bones[0] + if bone_index >= 0: + vertex_group_table[bone_index].add(index=idx, weight=1.0, type="ADD") + elif len(pv_bones) == 2: + vertex_group_table[pv_bones[0]].add(index=idx, weight=pv_weights[0], type="ADD") + vertex_group_table[pv_bones[1]].add(index=idx, weight=1.0 - pv_weights[0], type="ADD") + elif len(pv_bones) == 4: + for bone, weight in zip(pv_bones, pv_weights): + vertex_group_table[bone].add(index=idx, weight=weight, type="ADD") + else: + raise Exception("Unknown bone weight type.") + + vg_edge_scale.lock_weight = True + vg_vertex_order.lock_weight = True + + def __storeVerticesSDEF(self) -> None: + """Store SDEF vertex data for smooth deformation""" + if len(self.__sdefVertices) < 1: + return + + 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 + logger.info("Stored %d SDEF vertices", len(self.__sdefVertices)) + + def __importTextures(self) -> None: + """Import textures from the PMX model""" + pmxModel = self.__model + + self.__textureTable = [] + for i in pmxModel.textures: + self.__textureTable.append(bpy.path.resolve_ncase(path=i.path)) + + def __createEditBones(self, obj: Object, pmx_bones: List[Any]) -> 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 = [] + + from math import isfinite + + 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: + for i in pmx_bones: + bone = data.edit_bones.new(name=i.name) + loc = _VectorXZY(i.location) * self.__scale + bone.head = loc + editBoneTable.append(bone) + nameTable.append(bone.name) + + 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: + b_bone.parent = editBoneTable[m_bone.parent] + else: + b_bone.parent = editBoneTable[m_bone.parent].parent + + for b_bone, m_bone in zip(editBoneTable, pmx_bones): + if isinstance(m_bone.displayConnection, int): + if m_bone.displayConnection != -1: + b_bone.tail = editBoneTable[m_bone.displayConnection].head + else: + b_bone.tail = b_bone.head + else: + loc = _VectorXZY(m_bone.displayConnection) * self.__scale + b_bone.tail = b_bone.head + loc + + for b_bone, m_bone in zip(editBoneTable, pmx_bones): + if m_bone.isIK and m_bone.target != -1: + logger.debug("Checking IK links of %s", 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] + if self.__fix_IK_links or b_bone_link.length < 0.001: + 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: + logger.warning("Unsolved IK link %s", b_bone_link.name) + elif b_bone_tail.parent != b_bone_link: + logger.warning("Skipped IK link %s", b_bone_link.name) + elif (b_bone_link.tail - b_bone_tail.head).length > 1e-4: + logger.debug("Fix IK link %s", b_bone_link.name) + b_bone_link.tail = b_bone_link.head + loc + + 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 + else: + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + else: + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + if m_bone.displayConnection != -1 and m_bone.displayConnection != [0.0, 0.0, 0.0]: + logger.debug("Special tip bone %s, display %s", b_bone.name, str(m_bone.displayConnection)) + specialTipBones.append(b_bone.name) + + 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) + + 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] + if t.parent is None or t.parent != b_bone: + continue + if pmx_bones[m_bone.displayConnection].isMovable: + continue + if (b_bone.tail - t.head).length > 1e-4: + continue + if not m_bone.isMovable: + continue + logger.warning("Connected: %s (%d)-> %s", b_bone.name, len(b_bone.children), t.name) + t.use_connect = True + + return nameTable, specialTipBones + + def __sortPoseBonesByBoneIndex(self, pose_bones: List[bpy.types.PoseBone], bone_names: List[str]) -> List[bpy.types.PoseBone]: + """Sort pose bones by their bone index in the PMX file""" + r: List[bpy.types.PoseBone] = [] + for i in bone_names: + r.append(pose_bones[i]) + return r + + @staticmethod + def convertIKLimitAngles(min_angle: List[float], max_angle: List[float], bone_matrix: Matrix, invert: bool = False) -> Tuple[Vector, Vector]: + """Convert IK limit angles to Blender's coordinate system""" + mat = bone_matrix.to_3x3() * -1 + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + mat.transpose() + if invert: + mat.invert() + + # align matrix to global axes + m = Matrix([[0, 0, 0], [0, 0, 0], [0, 0, 0]]) + i_set, j_set = [0, 1, 2], [0, 1, 2] + for _ in range(3): + ii, jj = i_set[0], j_set[0] + for i in i_set: + for j in j_set: + if abs(mat[i][j]) > abs(mat[ii][jj]): + ii, jj = i, j + i_set.remove(ii) + j_set.remove(jj) + m[ii][jj] = -1 if mat[ii][jj] < 0 else 1 + + new_min_angle = m @ Vector(min_angle) + new_max_angle = m @ Vector(max_angle) + for i in range(3): + if new_min_angle[i] > new_max_angle[i]: + 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: int, pmx_bone: Any, pose_bones: List[bpy.types.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 + """ + ik_bone = pose_bones[index] + ik_target = pose_bones[pmx_bone.target] + ik_constraint_bone = ik_target.parent + is_valid_ik = False + 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] + logger.warning("Fix IK settings of IK bone (%s)", 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 + logger.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) + 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:])): + logger.warning("Invalid IK bone (%s): IK chain does not follow parent-child relationship", ik_bone.name) + return + if ik_constraint_bone is None or len(pmx_bone.ik_links) < 1: + logger.warning("Invalid IK bone (%s)", ik_bone.name) + return + + c = ik_target.constraints.new(type="DAMPED_TRACK") + c.name = "mmd_ik_target_override" + c.mute = True + c.influence = 0 + c.target = self.__armObj + c.subtarget = ik_constraint_bone.name + if not is_valid_ik or next((c for c in ik_constraint_bone.constraints if c.type == "IK" and c.is_valid), None): + c.name = "mmd_ik_target_custom" + c.subtarget = ik_bone.name # point to IK control bone + ik_bone.mmd_bone.ik_rotation_constraint = pmx_bone.rotationConstraint + use_custom_ik = True + else: + ik_constraint_bone.mmd_bone.ik_rotation_constraint = pmx_bone.rotationConstraint + use_custom_ik = False + + ikConst = self.__rig.create_ik_constraint(ik_constraint_bone, ik_bone) + ikConst.iterations = pmx_bone.loopCount + ikConst.chain_count = len(pmx_bone.ik_links) + if not is_valid_ik: + ikConst.pole_target = self.__armObj # make it an incomplete/invalid setting + for idx, i in enumerate(pmx_bone.ik_links): + if use_custom_ik or i.target in self.__blender_ik_links: + c = ik_bone.constraints.new(type="LIMIT_ROTATION") + c.mute = True + c.influence = 0 + c.name = "mmd_ik_limit_custom%d" % 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) + c.max_x, c.max_y, c.max_z = maximum + c.min_x, c.min_y, c.min_z = minimum + continue + self.__blender_ik_links.add(i.target) + if i.maximumAngle is not None: + bone = pose_bones[i.target] + minimum, maximum = self.convertIKLimitAngles(i.minimumAngle, i.maximumAngle, bone.bone.matrix_local) + + bone.use_ik_limit_x = True + bone.use_ik_limit_y = True + bone.use_ik_limit_z = True + bone.ik_max_x, bone.ik_max_y, bone.ik_max_z = maximum + bone.ik_min_x, bone.ik_min_y, bone.ik_min_z = minimum + + c = bone.constraints.new(type="LIMIT_ROTATION") + c.mute = not is_valid_ik + c.name = "mmd_ik_limit_override" + c.owner_space = "LOCAL" + c.max_x, c.max_y, c.max_z = maximum + c.min_x, c.min_y, c.min_z = minimum + c.use_limit_x = bone.ik_max_x != c.max_x or bone.ik_min_x != c.min_x + 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) -> None: + """Import bones from the PMX model""" + pmxModel = self.__model + + boneNameTable, specialTipBones = self.__createEditBones(self.__armObj, pmxModel.bones) + pose_bones = self.__sortPoseBonesByBoneIndex(self.__armObj.pose.bones, boneNameTable) + self.__boneTable = pose_bones + 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.name_j = b_bone.name # pmx_bone.name + mmd_bone.name_e = pmx_bone.name_e + mmd_bone.is_controllable = pmx_bone.isControllable + mmd_bone.transform_order = pmx_bone.transform_order + mmd_bone.transform_after_dynamics = pmx_bone.transAfterPhis + + if pmx_bone.displayConnection == -1 or pmx_bone.displayConnection == (0.0, 0.0, 0.0): + mmd_bone.is_tip = True + elif b_bone.name in specialTipBones: + mmd_bone.is_tip = True + + b_bone.bone.hide = not pmx_bone.visible # or mmd_bone.is_tip + + if not pmx_bone.isRotatable: + b_bone.lock_rotation = [True, True, True] + + if not pmx_bone.isMovable: + b_bone.lock_location = [True, True, True] + + if pmx_bone.isIK: + if 0 <= pmx_bone.target < len(pose_bones): + self.__applyIk(i, pmx_bone, pose_bones) + + if pmx_bone.hasAdditionalRotate or pmx_bone.hasAdditionalLocation: + bone_index, influ = pmx_bone.additionalTransform + mmd_bone.has_additional_rotation = pmx_bone.hasAdditionalRotate + mmd_bone.has_additional_location = pmx_bone.hasAdditionalLocation + mmd_bone.additional_transform_influence = influ + if 0 <= bone_index < len(pose_bones): + mmd_bone.additional_transform_bone = pose_bones[bone_index].name + + if pmx_bone.localCoordinate is not None: + mmd_bone.enabled_local_axes = True + mmd_bone.local_axis_x = pmx_bone.localCoordinate.x_axis + mmd_bone.local_axis_z = pmx_bone.localCoordinate.z_axis + + if pmx_bone.axis is not None: + mmd_bone.enabled_fixed_axis = True + mmd_bone.fixed_axis = pmx_bone.axis + + if not self.__apply_bone_fixed_axis and mmd_bone.is_tip: + b_bone.lock_rotation = [True, False, True] + b_bone.lock_location = [True, True, True] + b_bone.lock_scale = [True, True, True] + + def __importRigids(self) -> None: + """Import rigid bodies from the PMX model""" + 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)) + for i, (rigid, rigid_obj) in enumerate(zip(self.__model.rigids, rigid_pool)): + loc = Vector(rigid.location).xzy * self.__scale + rot = Vector(rigid.rotation).xzy * -1 + size = Vector(rigid.size).xzy if rigid.type == pmx.Rigid.TYPE_BOX else Vector(rigid.size) + + obj = FnRigidBody.setup_rigid_body_object( + obj=rigid_obj, + shape_type=rigid.type, + location=loc, + rotation=rot, + size=size * self.__scale, + dynamics_type=rigid.mode, + name=rigid.name, + name_e=rigid.name_e, + collision_group_number=rigid.collision_group_number, + collision_group_mask=[rigid.collision_group_mask & (1 << i) == 0 for i in range(16)], + mass=rigid.mass, + friction=rigid.friction, + angular_damping=rigid.rotation_attenuation, + linear_damping=rigid.velocity_attenuation, + bounce=rigid.bounce, + bone=None if rigid.bone == -1 or rigid.bone is None else self.__boneTable[rigid.bone].name, + ) + obj.hide_set(True) + MoveObject.set_index(obj, i) + self.__rigidTable[i] = obj + + logger.debug("Finished importing rigid bodies in %.2f seconds", time.time() - start_time) + + def __importJoints(self) -> None: + """Import joints from the PMX model""" + 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())) + for i, (joint, joint_obj) in enumerate(zip(self.__model.joints, joint_pool)): + loc = Vector(joint.location).xzy * self.__scale + rot = Vector(joint.rotation).xzy * -1 + + obj = FnRigidBody.setup_joint_object( + obj=joint_obj, + name=joint.name, + name_e=joint.name_e, + location=loc, + 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_rotation=Vector(joint.minimum_rotation).xzy * -1, + minimum_rotation=Vector(joint.maximum_rotation).xzy * -1, + spring_linear=Vector(joint.spring_constant).xzy, + spring_angular=Vector(joint.spring_rotation_constant).xzy, + ) + obj.hide_set(True) + MoveObject.set_index(obj, i) + + logger.debug("Finished importing joints in %.2f seconds", time.time() - start_time) + + def __importMaterials(self) -> None: + """Import materials from the PMX model""" + self.__importTextures() + + pmxModel = self.__model + + self.__materialFaceCountTable = [] + for i in pmxModel.materials: + mat = bpy.data.materials.new(name=self.__safe_name(i.name, max_length=50)) + self.__materialTable.append(mat) + mmd_mat = mat.mmd_material + mmd_mat.name_j = i.name + mmd_mat.name_e = i.name_e + mmd_mat.ambient_color = i.ambient + mmd_mat.diffuse_color = i.diffuse[0:3] + mmd_mat.alpha = i.diffuse[3] + mmd_mat.specular_color = i.specular + mmd_mat.shininess = i.shininess + mmd_mat.is_double_sided = i.is_double_sided + mmd_mat.enabled_drop_shadow = i.enabled_drop_shadow + mmd_mat.enabled_self_shadow_map = i.enabled_self_shadow_map + mmd_mat.enabled_self_shadow = i.enabled_self_shadow + mmd_mat.enabled_toon_edge = i.enabled_toon_edge + mmd_mat.edge_color = i.edge_color + mmd_mat.edge_weight = i.edge_size + mmd_mat.comment = i.comment + + self.__materialFaceCountTable.append(int(i.vertex_count / 3)) + self.__meshObj.data.materials.append(mat) + fnMat = FnMaterial(mat) + if i.texture != -1: + texture_slot = fnMat.create_texture(self.__textureTable[i.texture]) + texture_slot.texture.use_mipmap = self.__use_mipmap + self.__imageTable[len(self.__materialTable) - 1] = texture_slot.texture.image + + if i.is_shared_toon_texture: + mmd_mat.is_shared_toon_texture = True + mmd_mat.shared_toon_texture = i.toon_texture + else: + mmd_mat.is_shared_toon_texture = False + if i.toon_texture >= 0: + mmd_mat.toon_texture = self.__textureTable[i.toon_texture] + + if i.sphere_texture_mode == 2: + amount = self.__spa_blend_factor + else: + amount = self.__sph_blend_factor + if i.sphere_texture != -1 and amount != 0.0: + texture_slot = fnMat.create_sphere_texture(self.__textureTable[i.sphere_texture]) + texture_slot.diffuse_color_factor = amount + 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) + + def __importFaces(self) -> None: + """Import faces/polygons from the PMX model""" + pmxModel = self.__model + mesh = self.__meshObj.data + vertex_map = self.__vertex_map + + 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 + material_indices = tuple(i for i, c in enumerate(self.__materialFaceCountTable) for x in range(c)) + + mesh.loops.add(len(pmxModel.faces) * 3) + mesh.loops.foreach_set("vertex_index", loop_indices) + + mesh.polygons.add(len(pmxModel.faces)) + mesh.polygons.foreach_set("loop_start", tuple(range(0, len(mesh.loops), 3))) + mesh.polygons.foreach_set("loop_total", (3,) * len(pmxModel.faces)) + mesh.polygons.foreach_set("use_smooth", (True,) * len(pmxModel.faces)) + mesh.polygons.foreach_set("material_index", material_indices) + + uv_textures, uv_layers = getattr(mesh, "uv_textures", mesh.uv_layers), mesh.uv_layers + uv_tex = uv_textures.new() + uv_layer = uv_layers[uv_tex.name] + uv_table = {vi: self.flipUV_V(v.uv) for vi, v in enumerate(pmxModel.vertices)} + uv_layer.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in uv_table[i])) + + if hasattr(mesh, "uv_textures"): + for bf, mi in zip(uv_tex.data, material_indices): + bf.image = self.__imageTable.get(mi, None) + + if pmxModel.header and pmxModel.header.additional_uvs: + logger.info("Importing %d additional uvs", pmxModel.header.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] + logger.info(" - %s...(uv channels)", add_uv.name) + 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()): + logger.info("\t- zw are all zeros: %s", 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(): + logger.info(" - %s...(zw channels of %s)", name, name[1:]) + add_zw = uv_textures.new(name=name) + if add_zw is None: + logger.warning("\t* Lost zw channels") + continue + add_zw = uv_layers[add_zw.name] + add_zw.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in zw_table[i])) + + self.__fixOverlappingFaceMaterials(mesh.materials, mesh.vertices, loop_indices, material_indices) + + def __fixOverlappingFaceMaterials(self, materials: List[bpy.types.Material], + vertices: List[bpy.types.MeshVertex], + loop_indices: List[int], + material_indices: List[int]) -> None: + """Fix overlapping face materials to prevent z-fighting""" + # 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 = {} + mi_skip = -1 + _vi_cache = {} + + 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)) + return _vi_cache[vi] + + assert len(loop_indices) == len(material_indices) * 3 + for i, mi in enumerate(material_indices): + if mi <= mi_skip: + continue + si = 3 * i + verts = tuple(sorted((_rounded_co_vi(loop_indices[si]), _rounded_co_vi(loop_indices[si + 1]), _rounded_co_vi(loop_indices[si + 2])))) + if verts not in check: + check[verts] = mi + elif check[verts] < mi: + logger.debug("Fix blend method of material: %s", materials[mi].name) + materials[mi].blend_method = "BLEND" + materials[mi].show_transparent_back = False + mi_skip = mi + + def __importVertexMorphs(self) -> None: + """Import vertex morphs from the PMX model""" + mmd_root = self.__root.mmd_root + categories = self.CATEGORIES + self.__createBasisShapeKey() + for morph in (x for x in self.__model.morphs if isinstance(x, pmx.VertexMorph)): + shapeKey = self.__meshObj.shape_key_add(name=morph.name) + vtx_morph = mmd_root.vertex_morphs.add() + vtx_morph.name = morph.name + vtx_morph.name_e = morph.name_e + vtx_morph.category = categories.get(morph.category, "OTHER") + for md in morph.offsets: + shapeKeyPoint = shapeKey.data[md.index] + shapeKeyPoint.co += Vector(md.offset).xzy * self.__scale + + def __importMaterialMorphs(self) -> None: + """Import material morphs from the PMX model""" + mmd_root = self.__root.mmd_root + categories = self.CATEGORIES + for morph in (x for x in self.__model.morphs if isinstance(x, pmx.MaterialMorph)): + mat_morph = mmd_root.material_morphs.add() + mat_morph.name = morph.name + mat_morph.name_e = morph.name_e + mat_morph.category = categories.get(morph.category, "OTHER") + for morph_data in morph.offsets: + data = mat_morph.data.add() + data.related_mesh = self.__meshObj.data.name + if 0 <= morph_data.index < len(self.__materialTable): + data.material = self.__materialTable[morph_data.index].name + data.offset_type = ["MULT", "ADD"][morph_data.offset_type] + data.diffuse_color = morph_data.diffuse_offset + data.specular_color = morph_data.specular_offset + data.shininess = morph_data.shininess_offset + data.ambient_color = morph_data.ambient_offset + data.edge_color = morph_data.edge_color_offset + data.edge_weight = morph_data.edge_size_offset + 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 + + def __importBoneMorphs(self) -> None: + """Import bone morphs from the PMX model""" + 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_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") + 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) + data.location = converter.convert_location(morph_data.location_offset) + data.rotation = converter.convert_rotation(morph_data.rotation_offset) + + def __importUVMorphs(self) -> None: + """Import UV morphs from the PMX model""" + 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_morph = mmd_root.uv_morphs.add() + uv_morph.name = morph.name + uv_morph.name_e = morph.name_e + uv_morph.category = categories.get(morph.category, "OTHER") + uv_morph.uv_index = morph.uv_index + + 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" + + def __importGroupMorphs(self) -> None: + """Import group morphs from the PMX model""" + 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_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") + for morph_data in morph.offsets: + if not (0 <= morph_data.morph < len(pmx_morphs)): + continue + data = group_morph.data.add() + m = pmx_morphs[morph_data.morph] + data.name = m.name + data.morph_type = morph_types[m.type_index()] + data.factor = morph_data.factor + + def __importDisplayFrames(self) -> None: + """Import display frames from the PMX model""" + pmxModel = self.__model + root = self.__root + morph_types = self.MORPH_TYPES + + for i in pmxModel.display: + frame = root.mmd_root.display_item_frames.add() + frame.name = i.name + frame.name_e = i.name_e + frame.is_special = i.isSpecial + for disp_type, index in i.data: + item = frame.data.add() + if disp_type == 0: + item.type = "BONE" + item.name = self.__boneTable[index].name + elif disp_type == 1: + item.type = "MORPH" + morph = pmxModel.morphs[index] + item.name = morph.name + item.morph_type = morph_types[morph.type_index()] + else: + raise Exception("Unknown display item type.") + + FnBone.sync_bone_collections_from_display_item_frames(self.__armObj) + + def __addArmatureModifier(self, meshObj: Object, armObj: Object) -> None: + """Add armature modifier to mesh object""" + 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 + + def __assignCustomNormals(self) -> None: + """Assign custom normals to the mesh""" + mesh: bpy.types.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) + else: + custom_normals = [(Vector(v.normal).xzy).normalized() for v in self.__model.vertices] + mesh.normals_split_custom_set_from_vertices(custom_normals) + logger.info("Custom normals applied successfully") + + def __renameLRBones(self, use_underscore: bool) -> None: + """Rename left/right bones with proper naming convention""" + pose_bones = self.__armObj.pose.bones + for i in pose_bones: + self.__rig.renameBone(i.name, utils.convertNameToLR(i.name, use_underscore)) + + def __translateBoneNames(self) -> None: + """Translate bone names using the provided translator""" + pose_bones = self.__armObj.pose.bones + for i in pose_bones: + self.__rig.renameBone(i.name, self.__translator.translate(i.name)) + + def __fixRepeatedMorphName(self) -> None: + """Fix repeated morph names to ensure uniqueness""" + used_names = set() + for m in self.__model.morphs: + m.name = utils.unique_name(m.name or "Morph", used_names) + used_names.add(m.name) + + def execute(self, context: Context, **args) -> None: + """Execute the PMX import process with the given arguments""" + if "pmx" in args: + self.__model = args["pmx"] + else: + self.__model = pmx.load(args["filepath"]) + self.__fixRepeatedMorphName() + + types = args.get("types", set()) + clean_model = args.get("clean_model", False) + remove_doubles = args.get("remove_doubles", False) + self.__scale = args.get("scale", 1.0) + self.__use_mipmap = args.get("use_mipmap", True) + self.__sph_blend_factor = args.get("sph_blend_factor", 1.0) + self.__spa_blend_factor = args.get("spa_blend_factor", 1.0) + self.__fix_IK_links = args.get("fix_IK_links", False) + self.__apply_bone_fixed_axis = args.get("apply_bone_fixed_axis", False) + self.__translator = args.get("translator", None) + + logger.info("****************************************") + logger.info("Starting PMX import process") + logger.info("----------------------------------------") + + start_time = time.time() + + with ProgressTracker(context, 100, "Importing PMX Model") as progress: + self.__createObjects() + progress.step("Created base objects") + + if "MESH" in types: + if clean_model: + _PMXCleaner.clean(self.__model, "MORPHS" not in types) + if remove_doubles: + self.__vertex_map = _PMXCleaner.remove_doubles(self.__model, "MORPHS" not in types) + + progress.step("Preparing mesh data") + self.__createMeshObject() + progress.step("Importing vertices") + self.__importVertices() + progress.step("Importing materials") + self.__importMaterials() + progress.step("Importing faces") + self.__importFaces() + self.__meshObj.data.update() + progress.step("Assigning custom normals") + self.__assignCustomNormals() + progress.step("Processing SDEF vertices") + self.__storeVerticesSDEF() + + if "ARMATURE" in types: + progress.step("Preparing armature") + # for tracking bone order + if "MESH" not in types: + self.__createMeshObject() + self.__importVertexGroup() + progress.step("Importing bones") + self.__importBones() + if args.get("rename_LR_bones", False): + use_underscore = args.get("use_underscore", False) + self.__renameLRBones(use_underscore) + if self.__translator: + self.__translateBoneNames() + if self.__apply_bone_fixed_axis: + FnBone.apply_bone_fixed_axis(self.__armObj) + FnBone.apply_additional_transformation(self.__armObj) + + if "PHYSICS" in types: + progress.step("Importing rigid bodies") + self.__importRigids() + progress.step("Importing joints") + self.__importJoints() + + if "DISPLAY" in types: + progress.step("Importing display frames") + self.__importDisplayFrames() + else: + self.__rig.initialDisplayFrames() + + if "MORPHS" in types: + progress.step("Importing group morphs") + self.__importGroupMorphs() + progress.step("Importing vertex morphs") + self.__importVertexMorphs() + progress.step("Importing bone morphs") + self.__importBoneMorphs() + progress.step("Importing material morphs") + self.__importMaterialMorphs() + progress.step("Importing UV morphs") + self.__importUVMorphs() + + if self.__meshObj: + progress.step("Adding armature modifier") + self.__addArmatureModifier(self.__meshObj, self.__armObj) + + FnModel.change_mmd_ik_loop_factor(self.__root, args.get("ik_loop_factor", 1)) + utils.selectAObject(self.__root) + + logger.info("Finished importing the model in %.2f seconds", time.time() - start_time) + logger.info("----------------------------------------") + + +class _PMXCleaner: + """Helper class for cleaning PMX data during import""" + + @classmethod + def clean(cls, pmx_model: Any, 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 + + # clean face/vertex + cls.__clean_pmx_faces(pmx_faces, pmx_model.materials, lambda f: frozenset(f)) + + 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: + logger.info("Vertices are clean, no cleaning needed") + else: + new_vertex_count = 0 + for v in sorted(index_map): + if v != new_vertex_count: + pmx_vertices[new_vertex_count] = pmx_vertices[v] + index_map[v] = new_vertex_count + new_vertex_count += 1 + logger.warning("Removed %d unused vertices", len(pmx_vertices) - new_vertex_count) + del pmx_vertices[new_vertex_count:] + + # update vertex indices of faces + for f in pmx_faces: + f[:] = [index_map[v] for v in f] + + if mesh_only: + logger.info("Mesh-only cleaning completed") + return + + if not is_index_clean: + # clean vertex/uv morphs + def __update_index(x): + x.index = index_map.get(x.index, None) + return x.index is not None + + cls.__clean_pmx_morphs(pmx_model.morphs, __update_index) + logger.info("PMX cleaning completed") + + @classmethod + def remove_doubles(cls, pmx_model: Any, mesh_only: bool) -> Optional[Dict[int, Tuple[int, int]]]: + """Remove duplicate vertices from the PMX model""" + logger.info("Removing duplicate vertices...") + pmx_vertices = pmx_model.vertices + + vertex_map = [None] * len(pmx_vertices) + # gather vertex data + for i, v in enumerate(pmx_vertices): + vertex_map[i] = [tuple(v.co)] + if not mesh_only: + for i, m in enumerate(pmx_model.morphs): + if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): + continue + for x in m.offsets: + vertex_map[x.index].append((i,) + tuple(x.offset)) + # generate vertex merging table + keys = {} + for i, v in enumerate(vertex_map): + k = tuple(v) + if k in keys: + vertex_map[i] = keys[k] # merge pmx_vertices[i] to pmx_vertices[keys[k][0]] + else: + vertex_map[i] = keys[k] = (i, len(keys)) # (pmx index, blender index) + counts = len(vertex_map) - len(keys) + keys.clear() + if counts: + logger.warning("%d duplicate vertices will be removed", counts) + else: + logger.info("No duplicate vertices found") + return None + + # clean face + face_key_func = lambda f: frozenset({vertex_map[x][0]: tuple(pmx_vertices[x].uv) for x in f}.items()) + cls.__clean_pmx_faces(pmx_model.faces, pmx_model.materials, face_key_func) + + if mesh_only: + logger.info("Mesh-only duplicate removal completed") + else: + # clean vertex/uv morphs + def __update_index(x): + 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) + logger.info("Duplicate removal completed") + return vertex_map + + @staticmethod + def __clean_pmx_faces(pmx_faces: List[List[int]], pmx_materials: List[Any], face_key_func: Callable) -> None: + """Clean PMX faces by removing duplicates and updating material vertex counts""" + new_face_count = 0 + face_iter = iter(pmx_faces) + for mat in pmx_materials: + used_faces = set() + new_vertex_count = 0 + for i in range(int(mat.vertex_count / 3)): + f = next(face_iter) + + f_key = face_key_func(f) + if len(f_key) != 3 or f_key in used_faces: + continue + used_faces.add(f_key) + + pmx_faces[new_face_count] = list(f) + new_face_count += 1 + new_vertex_count += 3 + mat.vertex_count = new_vertex_count + face_iter = None + if new_face_count == len(pmx_faces): + logger.info("Faces are clean, no cleaning needed") + else: + logger.warning("Removed %d duplicate faces", len(pmx_faces) - new_face_count) + del pmx_faces[new_face_count:] + + @staticmethod + def __clean_pmx_morphs(pmx_morphs: List[Any], index_update_func: Callable) -> None: + """Clean PMX morphs by updating vertex indices and removing invalid offsets""" + for m in pmx_morphs: + if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): + continue + old_len = len(m.offsets) + m.offsets = [x for x in m.offsets if index_update_func(x)] + counts = old_len - len(m.offsets) + if counts: + logger.warning('Removed %d (of %d) offsets from morph "%s"', counts, old_len, m.name) diff --git a/core/mmd/__init__.py b/core/mmd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mmd/bone.py b/core/mmd/bone.py new file mode 100644 index 0000000..7ac61f2 --- /dev/null +++ b/core/mmd/bone.py @@ -0,0 +1,587 @@ +# -*- coding: utf-8 -*- +# Copyright 2013 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import math +from typing import TYPE_CHECKING, Iterable, Optional, Set + +import bpy +from mathutils import Vector + +from ..logging_setup import logger +from .. import common +from ..common import ProgressTracker +from ..bpyutils import TransformConstraintOp + +# Constants for bone collections +BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection" +BONE_COLLECTION_NAME_SHADOW = "mmd_shadow" +BONE_COLLECTION_NAME_DUMMY = "mmd_dummy" + +SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NAME_DUMMY] + + +def remove_constraint(constraints, name): + c = constraints.get(name, None) + if c: + constraints.remove(c) + return True + return False + + +def remove_edit_bones(edit_bones, bone_names): + for name in bone_names: + b = edit_bones.get(name, None) + if b: + edit_bones.remove(b) + + +class FnBone: + AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") + AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指") + AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") + + def __init__(self): + 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]: + for bone in armature_object.pose.bones: + if bone.mmd_bone.bone_id != bone_id: + continue + return bone + return None + + @staticmethod + def __new_bone_id(armature_object: bpy.types.Object) -> int: + return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1 + + @staticmethod + def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int: + if pose_bone.mmd_bone.bone_id < 0: + pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data) + return pose_bone.mmd_bone.bone_id + + @staticmethod + def __get_selected_pose_bones(armature_object: bpy.types.Object) -> Iterable[bpy.types.PoseBone]: + if armature_object.mode == "EDIT": + bpy.ops.object.mode_set(mode="OBJECT") # update selected bones + bpy.ops.object.mode_set(mode="EDIT") # back to edit mode + context_selected_bones = bpy.context.selected_pose_bones or bpy.context.selected_bones or [] + bones = armature_object.pose.bones + return (bones[b.name] for b in context_selected_bones if not bones[b.name].is_mmd_shadow_bone) + + @staticmethod + def setup_special_bone_collections(armature_object: bpy.types.Object) -> bpy.types.Object: + armature: bpy.types.Armature = armature_object.data + bone_collections = armature.collections + for bone_collection_name in SPECIAL_BONE_COLLECTION_NAMES: + if bone_collection_name in bone_collections: + continue + bone_collection = bone_collections.new(bone_collection_name) + FnBone.__set_bone_collection_to_special(bone_collection, is_visible=False) + return armature_object + + @staticmethod + def __is_mmd_tools_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection + + @staticmethod + def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + 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): + 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: + 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): + 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: + 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: + 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: + 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: + for bone_collection in edit_bone.collections: + if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + continue + bone_collection.unassign(edit_bone) + return edit_bone + + @staticmethod + def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object): + armature: bpy.types.Armature = armature_object.data + bone_collections = armature.collections + + from .model import FnModel + + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root + + bones = armature.bones + used_groups = set() + unassigned_bone_names = {b.name for b in bones} + + for frame in mmd_root.display_item_frames: + for item in frame.data: + if item.type == "BONE" and item.name in unassigned_bone_names: + unassigned_bone_names.remove(item.name) + group_name = frame.name + used_groups.add(group_name) + bone_collection = bone_collections.get(group_name) + if bone_collection is None: + bone_collection = bone_collections.new(name=group_name) + FnBone.__set_bone_collection_to_normal(bone_collection) + bone_collection.assign(bones[item.name]) + + for name in unassigned_bone_names: + for bc in bones[name].collections: + if not FnBone.__is_mmd_tools_bone_collection(bc): + continue + if not FnBone.__is_normal_bone_collection(bc): + continue + bc.unassign(bones[name]) + + # remove unused bone groups + for bone_collection in bone_collections.values(): + if bone_collection.name in used_groups: + continue + if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + continue + if not FnBone.__is_normal_bone_collection(bone_collection): + continue + 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 + + from .model import FnModel + + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root + display_item_frames = mmd_root.display_item_frames + + used_frame_index: Set[int] = set() + + bone_collection: bpy.types.BoneCollection + for bone_collection in bone_collections: + if len(bone_collection.bones) == 0 or FnBone.__is_special_bone_collection(bone_collection): + continue + + bone_collection_name = bone_collection.name + display_item_frame: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name) + if display_item_frame is None: + display_item_frame = display_item_frames.add() + display_item_frame.name = bone_collection_name + display_item_frame.name_e = bone_collection_name + used_frame_index.add(display_item_frames.find(bone_collection_name)) + + ItemOp.resize(display_item_frame.data, len(bone_collection.bones)) + for display_item, bone in zip(display_item_frame.data, bone_collection.bones): + display_item.type = "BONE" + display_item.name = bone.name + + for i in reversed(range(len(display_item_frames))): + if i in used_frame_index: + continue + display_item_frame = display_item_frames[i] + if display_item_frame.is_special: + if display_item_frame.name != "表情": + display_item_frame.data.clear() + else: + display_item_frames.remove(i) + mmd_root.active_display_item_frame = 0 + + @staticmethod + def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True): + for b in FnBone.__get_selected_pose_bones(armature_object): + mmd_bone = b.mmd_bone + mmd_bone.enabled_fixed_axis = enable + lock_rotation = b.lock_rotation[:] + if enable: + axes = b.bone.matrix_local.to_3x3().transposed() + if lock_rotation.count(False) == 1: + mmd_bone.fixed_axis = axes[lock_rotation.index(False)].xzy + else: + mmd_bone.fixed_axis = axes[1].xzy # Y-axis + elif all(b.lock_location) and lock_rotation.count(True) > 1 and lock_rotation == (b.lock_ik_x, b.lock_ik_y, b.lock_ik_z): + # unlock transform locks if fixed axis was applied + b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = (False, False, False) + b.lock_location = b.lock_scale = (False, False, False) + + @staticmethod + def apply_bone_fixed_axis(armature_object: bpy.types.Object): + with ProgressTracker(bpy.context, 100, "Applying Bone Fixed Axis") as progress: + bone_map = {} + for b in armature_object.pose.bones: + if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis: + continue + mmd_bone = b.mmd_bone + 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) + + progress.step("Processing bones") + + force_align = True + with common.edit_object(armature_object) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False + continue + fixed_axis, is_tip, parent_tip = bone_map[bone.name] + if fixed_axis.length: + axes = [bone.x_axis, bone.y_axis, bone.z_axis] + direction = fixed_axis.normalized().xzy + idx, val = max([(i, direction.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) + idx_1, idx_2 = (idx + 1) % 3, (idx + 2) % 3 + axes[idx] = -direction if val < 0 else direction + axes[idx_2] = axes[idx].cross(axes[idx_1]) + axes[idx_1] = axes[idx_2].cross(axes[idx]) + if parent_tip and bone.use_connect: + bone.use_connect = False + bone.head = bone.parent.head + if force_align: + tail = bone.head + axes[1].normalized() * bone.length + if is_tip or (tail - bone.tail).length > 1e-4: + for c in bone.children: + if c.use_connect: + c.use_connect = False + if is_tip: + c.head = bone.head + bone.tail = tail + bone.align_roll(axes[2]) + bone_map[bone.name] = tuple(i != idx for i in range(3)) + else: + bone_map[bone.name] = (True, True, True) + bone.select = True + + progress.step("Applying locks") + + for bone_name, locks in bone_map.items(): + b = armature_object.pose.bones[bone_name] + b.lock_location = (True, True, True) + 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): + for b in FnBone.__get_selected_pose_bones(armature_object): + mmd_bone = b.mmd_bone + mmd_bone.enabled_local_axes = enable + if enable: + axes = b.bone.matrix_local.to_3x3().transposed() + mmd_bone.local_axis_x = axes[0].xzy + mmd_bone.local_axis_z = axes[2].xzy + + @staticmethod + def apply_bone_local_axes(armature_object: bpy.types.Object): + with ProgressTracker(bpy.context, 100, "Applying Bone Local Axes") as progress: + bone_map = {} + for b in armature_object.pose.bones: + if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: + continue + mmd_bone = b.mmd_bone + bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z) + + progress.step("Processing bones") + + with common.edit_object(armature_object) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False + continue + local_axis_x, local_axis_z = bone_map[bone.name] + FnBone.update_bone_roll(bone, local_axis_x, local_axis_z) + bone.select = True + + @staticmethod + def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z): + axes = FnBone.get_axes(mmd_local_axis_x, mmd_local_axis_z) + idx, val = max([(i, edit_bone.vector.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) + edit_bone.align_roll(axes[(idx - 1) % 3 if val < 0 else (idx + 1) % 3]) + + @staticmethod + def get_axes(mmd_local_axis_x, mmd_local_axis_z): + x_axis = Vector(mmd_local_axis_x).normalized().xzy + z_axis = Vector(mmd_local_axis_z).normalized().xzy + y_axis = z_axis.cross(x_axis).normalized() + z_axis = x_axis.cross(y_axis).normalized() # correction + return (x_axis, y_axis, z_axis) + + @staticmethod + def apply_auto_bone_roll(armature): + with ProgressTracker(bpy.context, 100, "Applying Auto Bone Roll") as progress: + bone_names = [] + 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) + + progress.step("Processing bones") + + with common.edit_object(armature) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_names: + continue + FnBone.update_auto_bone_roll(bone) + bone.select = True + + @staticmethod + def update_auto_bone_roll(edit_bone): + # make a triangle face (p1,p2,p3) + p1 = edit_bone.head.copy() + p2 = edit_bone.tail.copy() + p3 = p2.copy() + # translate p3 in xz plane + # the normal vector of the face tracks -Y direction + xz = Vector((p2.x - p1.x, p2.z - p1.z)) + xz.normalize() + theta = math.atan2(xz.y, xz.x) + norm = edit_bone.vector.length + p3.z += norm * math.cos(theta) + p3.x -= norm * math.sin(theta) + # calculate the normal vector of the face + y = (p2 - p1).normalized() + z_tmp = (p3 - p1).normalized() + x = y.cross(z_tmp) # normal vector + # z = x.cross(y) + FnBone.update_bone_roll(edit_bone, y.xzy, x.xzy) + + @staticmethod + def has_auto_local_axis(name_j): + if name_j: + if name_j in FnBone.AUTO_LOCAL_AXIS_ARMS or name_j in FnBone.AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: + return True + for finger_name in FnBone.AUTO_LOCAL_AXIS_FINGERS: + if finger_name in name_j: + return True + return False + + @staticmethod + def clean_additional_transformation(armature_object: bpy.types.Object): + logger.info(f"Cleaning additional transformations for {armature_object.name}") + + # clean constraints + p_bone: bpy.types.PoseBone + for p_bone in armature_object.pose.bones: + p_bone.mmd_bone.is_additional_transform_dirty = True + constraints = p_bone.constraints + remove_constraint(constraints, "mmd_additional_rotation") + remove_constraint(constraints, "mmd_additional_location") + if remove_constraint(constraints, "mmd_additional_parent"): + p_bone.bone.use_inherit_rotation = True + + # clean shadow bones + shadow_bone_types = { + "DUMMY", + "SHADOW", + "ADDITIONAL_TRANSFORM", + "ADDITIONAL_TRANSFORM_INVERT", + } + + def __is_at_shadow_bone(b): + return b.is_mmd_shadow_bone and b.mmd_shadow_bone_type in shadow_bone_types + + shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)] + if len(shadow_bone_names) > 0: + with common.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): + with ProgressTracker(bpy.context, 100, "Applying Additional Transformations") as progress: + def __is_dirty_bone(b): + if b.is_mmd_shadow_bone: + return False + mmd_bone = b.mmd_bone + if mmd_bone.has_additional_rotation or mmd_bone.has_additional_location: + return True + return mmd_bone.is_additional_transform_dirty + + dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)] + + progress.step("Setting up constraints") + + # setup constraints + shadow_bone_pool = [] + for p_bone in dirty_bones: + sb = FnBone.__setup_constraints(p_bone) + if sb: + shadow_bone_pool.append(sb) + + progress.step("Setting up shadow bones") + + # setup shadow bones + with common.edit_object(armature_object) as data: + edit_bones = data.edit_bones + for sb in shadow_bone_pool: + sb.update_edit_bones(edit_bones) + + pose_bones = armature_object.pose.bones + for sb in shadow_bone_pool: + sb.update_pose_bones(pose_bones) + + progress.step("Finalizing") + + # finish + for p_bone in dirty_bones: + p_bone.mmd_bone.is_additional_transform_dirty = False + + @staticmethod + def __setup_constraints(p_bone): + bone_name = p_bone.name + mmd_bone = p_bone.mmd_bone + influence = mmd_bone.additional_transform_influence + target_bone = mmd_bone.additional_transform_bone + mute_rotation = not mmd_bone.has_additional_rotation + mute_location = not mmd_bone.has_additional_location + + constraints = p_bone.constraints + if not target_bone or (mute_rotation and mute_location) or influence == 0: + rot = remove_constraint(constraints, "mmd_additional_rotation") + loc = remove_constraint(constraints, "mmd_additional_location") + if rot or loc: + return _AT_ShadowBoneRemove(bone_name) + return None + + shadow_bone = _AT_ShadowBoneCreate(bone_name, target_bone) + + def __config(name, mute, map_type, value): + if mute: + remove_constraint(constraints, name) + return + c = TransformConstraintOp.create(constraints, name, map_type) + c.target = p_bone.id_data + shadow_bone.add_constraint(c) + TransformConstraintOp.update_min_max(c, value, influence) + + __config("mmd_additional_rotation", mute_rotation, "ROTATION", math.pi) + __config("mmd_additional_location", mute_location, "LOCATION", 100) + + return shadow_bone + + @staticmethod + def update_additional_transform_influence(pose_bone: bpy.types.PoseBone): + influence = pose_bone.mmd_bone.additional_transform_influence + constraints = pose_bone.constraints + c = constraints.get("mmd_additional_rotation", None) + TransformConstraintOp.update_min_max(c, math.pi, influence) + c = constraints.get("mmd_additional_location", None) + TransformConstraintOp.update_min_max(c, 100, influence) + + +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): + with ProgressTracker(bpy.context, 100, "Fixing MMD IK Limit Override") as progress: + pose_bone: bpy.types.PoseBone + for pose_bone in armature_object.pose.bones: + constraint: bpy.types.Constraint + for constraint in pose_bone.constraints: + if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name: + constraint.owner_space = "LOCAL" + + progress.step("Fixed IK limit overrides") + + +class _AT_ShadowBoneRemove: + def __init__(self, bone_name): + self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name) + + def update_edit_bones(self, edit_bones): + remove_edit_bones(edit_bones, self.__shadow_bone_names) + + def update_pose_bones(self, pose_bones): + pass + + +class _AT_ShadowBoneCreate: + def __init__(self, bone_name, target_bone_name): + self.__dummy_bone_name = "_dummy_" + bone_name + self.__shadow_bone_name = "_shadow_" + bone_name + self.__bone_name = bone_name + self.__target_bone_name = target_bone_name + self.__constraint_pool = [] + + def __is_well_aligned(self, bone0, bone1): + return bone0.x_axis.dot(bone1.x_axis) > 0.99 and bone0.y_axis.dot(bone1.y_axis) > 0.99 + + def __update_constraints(self, use_shadow=True): + subtarget = self.__shadow_bone_name if use_shadow else self.__target_bone_name + for c in self.__constraint_pool: + c.subtarget = subtarget + + def add_constraint(self, constraint): + self.__constraint_pool.append(constraint) + + def update_edit_bones(self, edit_bones): + bone = edit_bones[self.__bone_name] + target_bone = edit_bones[self.__target_bone_name] + if bone != target_bone and self.__is_well_aligned(bone, target_bone): + _AT_ShadowBoneRemove(self.__bone_name).update_edit_bones(edit_bones) + return + + dummy_bone_name = self.__dummy_bone_name + dummy = edit_bones.get(dummy_bone_name, None) or FnBone.set_edit_bone_to_dummy(edit_bones.new(name=dummy_bone_name)) + dummy.parent = target_bone + dummy.head = target_bone.head + dummy.tail = dummy.head + bone.tail - bone.head + dummy.roll = bone.roll + + 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)) + shadow.parent = target_bone.parent + shadow.head = dummy.head + shadow.tail = dummy.tail + shadow.roll = bone.roll + + def update_pose_bones(self, pose_bones): + if self.__shadow_bone_name not in pose_bones: + 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" + + shadow_p_bone = pose_bones[self.__shadow_bone_name] + shadow_p_bone.is_mmd_shadow_bone = True + shadow_p_bone.mmd_shadow_bone_type = "SHADOW" + + if "mmd_tools_at_dummy" not in shadow_p_bone.constraints: + c = shadow_p_bone.constraints.new("COPY_TRANSFORMS") + c.name = "mmd_tools_at_dummy" + c.target = dummy_p_bone.id_data + c.subtarget = dummy_p_bone.name + c.target_space = "POSE" + c.owner_space = "POSE" + + self.__update_constraints() diff --git a/core/mmd/core/bpyutils.py b/core/mmd/core/bpyutils.py new file mode 100644 index 0000000..2800d3c --- /dev/null +++ b/core/mmd/core/bpyutils.py @@ -0,0 +1,533 @@ +# -*- coding: utf-8 -*- +# Copyright 2013 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import contextlib +from typing import Generator, List, Optional, TypeVar, Dict, Any, Set, Tuple, Type + +import bpy +from bpy.types import Object, Material, Context +from mathutils import Vector, Matrix + +from ...logging_setup import logger +from ...addon_preferences import get_preference, save_preference + + +class __EditMode: + """Context manager for edit mode operations""" + def __init__(self, obj: Object): + if not isinstance(obj, bpy.types.Object): + raise ValueError("Expected a Blender Object") + self.__prevMode = obj.mode + self.__obj = obj + self.__obj_select = obj.select_get() + with select_object(obj): + if obj.mode != "EDIT": + bpy.ops.object.mode_set(mode="EDIT") + + def __enter__(self): + return self.__obj.data + + def __exit__(self, type, value, traceback): + if self.__prevMode == "EDIT": + bpy.ops.object.mode_set(mode="OBJECT") # update edited data + bpy.ops.object.mode_set(mode=self.__prevMode) + self.__obj.select_set(self.__obj_select) + + +class __SelectObjects: + """Context manager for object selection operations""" + def __init__(self, active_object: Object, selected_objects: Optional[List[Object]] = None): + if not isinstance(active_object, bpy.types.Object): + raise ValueError("Expected a Blender Object") + try: + bpy.ops.object.mode_set(mode="OBJECT") + except Exception: + pass + + context = FnContext.ensure_context() + + for i in context.selected_objects: + i.select_set(False) + + self.__active_object = active_object + self.__selected_objects = tuple(set(selected_objects) | set([active_object])) if selected_objects else (active_object,) + + self.__hides: List[bool] = [] + for i in self.__selected_objects: + self.__hides.append(i.hide_get()) + FnContext.select_object(context, i) + FnContext.set_active_object(context, active_object) + + def __enter__(self) -> Object: + return self.__active_object + + def __exit__(self, type, value, traceback): + for i, j in zip(self.__selected_objects, self.__hides): + i.hide_set(j) + + +def setParent(obj: Object, parent: Object) -> None: + """Set parent relationship between objects""" + with select_object(parent, objects=[parent, obj]): + bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False) + + +def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: + """Set parent relationship to a specific bone""" + with select_object(parent, objects=[parent, obj]): + bpy.ops.object.mode_set(mode="POSE") + parent.data.bones.active = parent.data.bones[bone_name] + bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False) + bpy.ops.object.mode_set(mode="OBJECT") + + +def edit_object(obj: Object): + """Set the object interaction mode to 'EDIT' + + It is recommended to use 'edit_object' with 'with' statement like the following code. + + with edit_object: + some functions... + """ + return __EditMode(obj) + + +def select_object(obj: Object, objects: Optional[List[Object]] = None): + """Select objects. + + It is recommended to use 'select_object' with 'with' statement like the following code. + This function can select "hidden" objects safely. + + with select_object(obj): + some functions... + """ + return __SelectObjects(obj, objects) + + +def duplicateObject(obj: Object, total_len: int) -> List[Object]: + """Duplicate an object multiple times""" + return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len) + + +def createObject(name: str = "Object", object_data: Optional[Any] = None, target_scene: Optional[Any] = None) -> Object: + """Create a new object and link it to the scene""" + context = FnContext.ensure_context(target_scene) + return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data)) + + +def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object: + """Create a sphere mesh object""" + import bmesh + + if target_object is None: + target_object = createObject(name="Sphere") + + mesh = target_object.data + bm = bmesh.new() + bmesh.ops.create_uvsphere( + bm, + u_segments=segment, + v_segments=ring_count, + radius=radius, + ) + for f in bm.faces: + f.smooth = True + bm.to_mesh(mesh) + bm.free() + return target_object + + +def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optional[Object] = None) -> Object: + """Create a box mesh object""" + import bmesh + from mathutils import Matrix + + if target_object is None: + target_object = createObject(name="Box") + + mesh = target_object.data + bm = bmesh.new() + bmesh.ops.create_cube( + bm, + size=2, + matrix=Matrix([[size[0], 0, 0, 0], [0, size[1], 0, 0], [0, 0, size[2], 0], [0, 0, 0, 1]]), + ) + for f in bm.faces: + f.smooth = True + bm.to_mesh(mesh) + bm.free() + return target_object + + +def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, height: float = 1.0, target_object: Optional[Object] = None) -> Object: + """Create a capsule mesh object""" + import math + import bmesh + + if target_object is None: + target_object = createObject(name="Capsule") + height = max(height, 1e-3) + + mesh = target_object.data + bm = bmesh.new() + verts = bm.verts + top = (0, 0, height / 2 + radius) + verts.new(top) + + f = lambda i: radius * math.sin(0.5 * math.pi * i / ring_count) + for i in range(ring_count, 0, -1): + z = f(i - 1) + t = math.sqrt(radius**2 - z**2) + for j in range(segment): + theta = 2 * math.pi / segment * j + x = t * math.sin(-theta) + y = t * math.cos(-theta) + verts.new((x, y, z + height / 2)) + + for i in range(ring_count): + z = -f(i) + t = math.sqrt(radius**2 - z**2) + for j in range(segment): + theta = 2 * math.pi / segment * j + x = t * math.sin(-theta) + y = t * math.cos(-theta) + verts.new((x, y, z - height / 2)) + + bottom = (0, 0, -(height / 2 + radius)) + verts.new(bottom) + if hasattr(verts, "ensure_lookup_table"): + verts.ensure_lookup_table() + + faces = bm.faces + for i in range(1, segment): + faces.new([verts[x] for x in (0, i, i + 1)]) + faces.new([verts[x] for x in (0, segment, 1)]) + offset = segment + 1 + for i in range(ring_count * 2 - 1): + for j in range(segment - 1): + t = offset + j + faces.new([verts[x] for x in (t - segment, t, t + 1, t - segment + 1)]) + faces.new([verts[x] for x in (offset - 1, offset + segment - 1, offset, offset - segment)]) + offset += segment + for i in range(segment - 1): + t = offset + i + faces.new([verts[x] for x in (t - segment, offset, t - segment + 1)]) + faces.new([verts[x] for x in (offset - 1, offset, offset - segment)]) + + for f in bm.faces: + f.smooth = True + bm.normal_update() + bm.to_mesh(mesh) + bm.free() + return target_object + + +class TransformConstraintOp: + """Helper class for transform constraints""" + __MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"} + + @staticmethod + def create(constraints, name: str, map_type: str): + """Create a transform constraint""" + c = constraints.get(name, None) + if c and c.type != "TRANSFORM": + constraints.remove(c) + c = None + if c is None: + c = constraints.new("TRANSFORM") + c.name = name + c.use_motion_extrapolate = True + c.target_space = c.owner_space = "LOCAL" + c.map_from = c.map_to = map_type + c.map_to_x_from = "X" + c.map_to_y_from = "Y" + c.map_to_z_from = "Z" + c.influence = 1 + return c + + @classmethod + def min_max_attributes(cls, map_type: str, name_id: str = "") -> Tuple[str, ...]: + """Get min/max attribute names for a constraint type""" + key = (map_type, name_id) + ret = cls.__MIN_MAX_MAP.get(key, None) + if ret is None: + defaults = (i + j + k for i in ("from_", "to_") for j in ("min_", "max_") for k in "xyz") + extension = cls.__MIN_MAX_MAP.get(map_type, "") + ret = cls.__MIN_MAX_MAP[key] = tuple(n + extension for n in defaults if name_id in n) + return ret + + @classmethod + def update_min_max(cls, constraint, value: float, influence: Optional[float] = 1): + """Update min/max values for a constraint""" + c = constraint + if not c or c.type != "TRANSFORM": + return + + for attr in cls.min_max_attributes(c.map_from, "from_min"): + setattr(c, attr, -value) + for attr in cls.min_max_attributes(c.map_from, "from_max"): + setattr(c, attr, value) + + if influence is None: + return + + for attr in cls.min_max_attributes(c.map_to, "to_min"): + setattr(c, attr, -value * influence) + for attr in cls.min_max_attributes(c.map_to, "to_max"): + setattr(c, attr, value * influence) + + +class FnObject: + """Function collection for object operations""" + def __init__(self): + raise NotImplementedError("This class is not expected to be instantiated.") + + @staticmethod + def mesh_remove_shape_key(mesh_object: Object, shape_key: bpy.types.ShapeKey) -> None: + """Remove a shape key from a mesh object, cleaning up drivers""" + assert isinstance(mesh_object.data, bpy.types.Mesh) + + key: bpy.types.Key = shape_key.id_data + assert key == mesh_object.data.shape_keys + + if mesh_object.animation_data is not None: + for fc_curve in mesh_object.animation_data.drivers: + if not fc_curve.data_path.startswith(shape_key.path_from_id()): + continue + mesh_object.driver_remove(fc_curve.data_path) + + key_blocks = key.key_blocks + + last_index = mesh_object.active_shape_key_index or 0 + if last_index >= key_blocks.find(shape_key.name): + last_index = max(0, last_index - 1) + + mesh_object.shape_key_remove(shape_key) + mesh_object.active_shape_key_index = min(last_index, len(key_blocks) - 1) + + +T = TypeVar("T") + + +class FnContext: + """Function collection for context operations""" + def __init__(self): + raise NotImplementedError("This class is not expected to be instantiated.") + + @staticmethod + def ensure_context(context: Optional[Context] = None) -> Context: + """Get a valid context, using bpy.context if none provided""" + return context or bpy.context + + @staticmethod + def get_active_object(context: Context) -> Optional[Object]: + """Get the active object from context safely""" + if context is None or not hasattr(context, 'active_object'): + return None + return context.active_object + + @staticmethod + def set_active_object(context: Context, obj: Object) -> Object: + """Set the active object in context""" + context.view_layer.objects.active = obj + return obj + + @staticmethod + def set_active_and_select_single_object(context: Context, obj: Object) -> Object: + """Set an object as active and the only selected object""" + return FnContext.set_active_object(context, FnContext.select_single_object(context, obj)) + + @staticmethod + def get_scene_objects(context: Context) -> List[Object]: + """Get all objects in the scene safely""" + if context is None or not hasattr(context, 'scene') or not hasattr(context.scene, 'objects'): + return [] + return context.scene.objects + + @staticmethod + def ensure_selectable(context: Context, obj: Object) -> Object: + """Make sure an object is selectable by unhiding it and its collections""" + 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: + for lc in layer_collection.children: + if __layer_check(lc): + lc.hide_viewport = False + lc.collection.hide_viewport = False + lc.collection.hide_select = False + return True + if obj in layer_collection.collection.objects.values(): + if layer_collection.exclude: + layer_collection.exclude = False + return True + return False + + selected_objects = set(context.selected_objects) + __layer_check(context.view_layer.layer_collection) + if len(context.selected_objects) != len(selected_objects): + for i in context.selected_objects: + if i not in selected_objects: + i.select_set(False) + return obj + + @staticmethod + def select_object(context: Context, obj: Object) -> Object: + """Select an object in the context""" + FnContext.ensure_selectable(context, obj).select_set(True) + return obj + + @staticmethod + def select_objects(context: Context, *objects: Object) -> List[Object]: + """Select multiple objects in the context""" + return [FnContext.select_object(context, obj) for obj in objects] + + @staticmethod + def select_single_object(context: Context, obj: Object) -> Object: + """Select only the specified object, deselecting all others""" + for i in context.selected_objects: + if i != obj: + i.select_set(False) + return FnContext.select_object(context, obj) + + @staticmethod + def link_object(context: Context, obj: Object) -> Object: + """Link an object to the active collection""" + context.collection.objects.link(obj) + return obj + + @staticmethod + def new_and_link_object(context: Context, name: str, object_data: Optional[Any]) -> Object: + """Create a new object and link it to the active collection""" + return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data)) + + @staticmethod + def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]: + """ + Duplicate an object to reach the target count. + + Args: + context: The context in which the duplication is performed + object_to_duplicate: The object to be duplicated + target_count: The desired count of duplicated objects + + Returns: + A list of duplicated objects + """ + for o in context.selected_objects: + o.select_set(False) + object_to_duplicate.select_set(True) + assert len(context.selected_objects) == 1 + assert context.selected_objects[0] == object_to_duplicate + last_selected_objects = result_objects = [object_to_duplicate] + while len(result_objects) < target_count: + bpy.ops.object.duplicate() + result_objects.extend(context.selected_objects) + remain = target_count - len(result_objects) - len(context.selected_objects) + if remain < 0: + last_selected_objects = context.selected_objects + for i in range(-remain): + last_selected_objects[i].select_set(False) + else: + for i in range(min(remain, len(last_selected_objects))): + last_selected_objects[i].select_set(True) + last_selected_objects = context.selected_objects + assert len(result_objects) == target_count + return result_objects + + @staticmethod + def find_user_layer_collection_by_object(context: Context, target_object: Object) -> Optional[bpy.types.LayerCollection]: + """ + Find the layer collection containing the target object. + + Args: + context: The Blender context + target_object: The target object to find the layer collection for + + Returns: + The layer collection containing the target object, or None if not found + """ + scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection + + def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]: + if layer_collection.name == name: + return layer_collection + + for child_layer_collection in layer_collection.children: + found = find_layer_collection_by_name(child_layer_collection, name) + if found is not None: + return found + + return None + + 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: + return found + + return None + + @staticmethod + @contextlib.contextmanager + def temp_override_active_layer_collection(context: Context, target_object: Object) -> Generator[Context, None, None]: + """ + Temporarily override the active layer collection to the one containing the target object. + + Args: + context: The context to modify + target_object: The object whose collection should become active + + Yields: + The modified context + """ + original_layer_collection = context.view_layer.active_layer_collection + target_layer_collection = FnContext.find_user_layer_collection_by_object(context, target_object) + if target_layer_collection is not None: + context.view_layer.active_layer_collection = target_layer_collection + try: + yield context + finally: + if context.view_layer.active_layer_collection.name != original_layer_collection.name: + context.view_layer.active_layer_collection = original_layer_collection + + @staticmethod + @contextlib.contextmanager + def temp_override_objects( + context: Context, + active_object: Optional[Object] = None, + selected_objects: Optional[List[Object]] = None, + **keywords + ) -> Generator[Context, None, None]: + """Create a temporary context override for object operations using Blender 4.4+ temp_override.""" + override_dict = {} + + if active_object is not None: + override_dict["active_object"] = active_object + override_dict["object"] = active_object + + if selected_objects is not None: + override_dict["selected_objects"] = selected_objects + override_dict["selected_editable_objects"] = selected_objects + + override_dict.update(keywords) + + with context.temp_override(**override_dict) as override_context: + yield override_context + + @staticmethod + def get_preference(key: str, default: T = None) -> T: + """ + Get a preference value using Avatar Toolkit's preference system.""" + return get_preference(key, default) + + @staticmethod + def save_preference(key: str, value: Any) -> None: + """Save a preference value using Avatar Toolkit's preference system.""" + save_preference(key, value) \ No newline at end of file diff --git a/core/mmd/core/utils.py b/core/mmd/core/utils.py new file mode 100644 index 0000000..4a6f5df --- /dev/null +++ b/core/mmd/core/utils.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +# Copyright 2013 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import logging +import os +import re +from typing import Callable, Optional, Set, List, Dict, Any + +import bpy +from bpy.types import Object, Context, Bone, PoseBone + +from ...logging_setup import logger +from .bpyutils import FnContext + + +def selectAObject(obj: Object) -> None: + """Select a single object and make it active""" + try: + bpy.ops.object.mode_set(mode="OBJECT") + except Exception: + logger.debug(f"Failed to set object mode for {obj.name}") + + bpy.ops.object.select_all(action="DESELECT") + FnContext.select_object(FnContext.ensure_context(), obj) + FnContext.set_active_object(FnContext.ensure_context(), obj) + + +def enterEditMode(obj: Object) -> None: + """Enter edit mode for the specified object""" + selectAObject(obj) + if obj.mode != "EDIT": + bpy.ops.object.mode_set(mode="EDIT") + + +def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: + """Set an object's parent to a specific bone""" + selectAObject(obj) + FnContext.set_active_object(FnContext.ensure_context(), parent) + bpy.ops.object.mode_set(mode="POSE") + parent.data.bones.active = parent.data.bones[bone_name] + bpy.ops.object.parent_set(type="BONE", keep_transform=False) + bpy.ops.object.mode_set(mode="OBJECT") + + +def selectSingleBone(context: Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None: + """Select a single bone in an armature""" + try: + bpy.ops.object.mode_set(mode="OBJECT") + except Exception: + logger.debug(f"Failed to set object mode for bone selection: {bone_name}") + + for i in context.selected_objects: + i.select_set(False) + + FnContext.set_active_object(context, armature) + bpy.ops.object.mode_set(mode="POSE") + + if reset_pose: + for p_bone in armature.pose.bones: + p_bone.matrix_basis.identity() + + armature_bones = armature.data.bones + for bone in armature_bones: + bone.select = bone.name == bone_name + bone.select_head = bone.select_tail = bone.select + if bone.select: + armature_bones.active = bone + bone.hide = False + + +# Regular expressions for name conversion +__CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$") +__CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$") + + +def convertNameToLR(name: str, use_underscore: bool = False) -> str: + """Convert Japanese left/right naming to Blender's L/R convention""" + m = __CONVERT_NAME_TO_L_REGEXP.match(name) + delimiter = "_" if use_underscore else "." + if m: + name = m.group(1) + m.group(2) + delimiter + "L" + m = __CONVERT_NAME_TO_R_REGEXP.match(name) + if m: + name = m.group(1) + m.group(2) + delimiter + "R" + return name + + +__CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[lL])(?P($|(?P=separator)))") +__CONVERT_R_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[rR])(?P($|(?P=separator)))") + + +def convertLRToName(name: str) -> str: + """Convert Blender's L/R convention to Japanese left/right naming""" + match = __CONVERT_L_TO_NAME_REGEXP.search(name) + if match: + return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}" + + match = __CONVERT_R_TO_NAME_REGEXP.search(name) + if match: + return f"右{name[0:match.start()]}{match['after']}{name[match.end():]}" + + return name + + +def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_group_name: str) -> None: + """Merge weights from source vertex group to destination vertex group""" + mesh = meshObj.data + src_vertex_group = meshObj.vertex_groups[src_vertex_group_name] + dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name] + + vtxIndex = src_vertex_group.index + for v in mesh.vertices: + try: + gi = [i.group for i in v.groups].index(vtxIndex) + dest_vertex_group.add([v.index], v.groups[gi].weight, "ADD") + except ValueError: + pass + + +def separateByMaterials(meshObj: Object) -> None: + """Separate a mesh object by materials""" + if len(meshObj.data.materials) < 2: + selectAObject(meshObj) + return + + matrix_parent_inverse = meshObj.matrix_parent_inverse.copy() + prev_parent = meshObj.parent + dummy_parent = bpy.data.objects.new(name="tmp", object_data=None) + bpy.context.collection.objects.link(dummy_parent) + + meshObj.parent = dummy_parent + meshObj.active_shape_key_index = 0 + + try: + enterEditMode(meshObj) + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.separate(type="MATERIAL") + finally: + bpy.ops.object.mode_set(mode="OBJECT") + + for i in dummy_parent.children: + materials = i.data.materials + i.name = getattr(materials[0], "name", "None") if len(materials) else "None" + i.parent = prev_parent + i.matrix_parent_inverse = matrix_parent_inverse + + bpy.data.objects.remove(dummy_parent) + + +def clearUnusedMeshes() -> None: + """Remove unused mesh data blocks""" + meshes_to_delete = [] + for mesh in bpy.data.meshes: + if mesh.users == 0: + meshes_to_delete.append(mesh) + + for mesh in meshes_to_delete: + bpy.data.meshes.remove(mesh) + + +def makePmxBoneMap(armObj: Object) -> Dict[str, PoseBone]: + """Create a mapping from bone names to pose bones""" + return {(i.mmd_bone.name_j or i.name): i for i in armObj.pose.bones} + + +__REMOVE_PREFIX_DIGITS_REGEXP = re.compile(r"\.\d{1,}$") + + +def unique_name(name: str, used_names: Set[str]) -> str: + """Create a unique name that doesn't exist in the used_names set + + Args: + name (str): The name to make unique + used_names (Set[str]): A set of names that are already used + + Returns: + str: The unique name, formatted as "{name}.{number:03d}" + """ + if name not in used_names: + return name + + count = 1 + new_name = orig_name = __REMOVE_PREFIX_DIGITS_REGEXP.sub("", name) + + while new_name in used_names: + new_name = f"{orig_name}.{count:03d}" + count += 1 + + return new_name + + +def saferelpath(path: str, start: str, strategy: str = "inside") -> str: + """Safely get a relative path, handling different drive issues on Windows + + Strategies: + - inside: returns the basename of the path + - outside: prepends '..' to the basename if on different drive + - absolute: returns the absolute path + """ + if strategy == "inside": + return os.path.basename(path) + + if strategy == "absolute": + return os.path.abspath(path) + + if strategy == "outside" and os.name == "nt": + d1, _ = os.path.splitdrive(path) + d2, _ = os.path.splitdrive(start) + if d1 != d2: + return ".." + os.sep + os.path.basename(path) + + return os.path.relpath(path, start) + + +class ItemOp: + """Operations for managing collections of items""" + + @staticmethod + def get_by_index(items: List[Any], index: int) -> Optional[Any]: + """Get an item by index with bounds checking""" + if 0 <= index < len(items): + return items[index] + return None + + @staticmethod + def resize(items: bpy.types.bpy_prop_collection, length: int) -> None: + """Resize a collection to the specified length""" + count = length - len(items) + if count > 0: + for i in range(count): + items.add() + elif count < 0: + for i in range(-count): + items.remove(length) + + @staticmethod + def add_after(items: bpy.types.bpy_prop_collection, index: int) -> tuple: + """Add a new item after the specified index""" + index_end = len(items) + index = max(0, min(index_end, index + 1)) + items.add() + items.move(index_end, index) + return items[index], index + + +class ItemMoveOp: + """Operations for moving items in collections""" + + @staticmethod + def move(items: bpy.types.bpy_prop_collection, index: int, move_type: str, + index_min: int = 0, index_max: Optional[int] = None) -> int: + """Move an item in a collection + + Args: + items: The collection to modify + index: Current index of the item + move_type: Type of move ('UP', 'DOWN', 'TOP', 'BOTTOM') + index_min: Minimum allowed index + index_max: Maximum allowed index + + Returns: + int: The new index after moving + """ + if index_max is None: + index_max = len(items) - 1 + else: + index_max = min(index_max, len(items) - 1) + + index_min = min(index_min, index_max) + + if index < index_min: + items.move(index, index_min) + return index_min + elif index > index_max: + items.move(index, index_max) + return index_max + + index_new = index + if move_type == "UP": + index_new = max(index_min, index - 1) + elif move_type == "DOWN": + index_new = min(index + 1, index_max) + elif move_type == "TOP": + index_new = index_min + elif move_type == "BOTTOM": + index_new = index_max + + if index_new != index: + items.move(index, index_new) + + return index_new diff --git a/core/mmd/material.py b/core/mmd/material.py new file mode 100644 index 0000000..576e212 --- /dev/null +++ b/core/mmd/material.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- +# Copyright 2013 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import logging +import os +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast + +import bpy +from mathutils import Vector + +from ..logging_setup import logger +from .exceptions import MaterialNotFoundError +from .shader import _NodeGroupUtils + +if TYPE_CHECKING: + from ..properties.material import MMDMaterial + +# Constants for sphere modes +SPHERE_MODE_OFF = 0 +SPHERE_MODE_MULT = 1 +SPHERE_MODE_ADD = 2 +SPHERE_MODE_SUBTEX = 3 + + +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 + logger.debug(f"Initializing FnMaterial for {material.name}") + + @staticmethod + def set_nodes_are_readonly(nodes_are_readonly: bool): + FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly + + @classmethod + def from_material_id(cls, material_id: str): + 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]): + materials = obj.data.materials + materials_pop = materials.pop + for i in sorted((x for x, m in enumerate(materials) if can_remove(m)), reverse=True): + m = materials_pop(index=i) + if m.users < 1: + bpy.data.materials.remove(m) + + @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]: + """ + 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. + The reference to materials can be indexes or names + Finally it will also swap the material slots if the option is given. + + Args: + mesh_object (bpy.types.Object): The mesh object + mat1_ref (str | int): The reference to the first material + mat2_ref (str | int): The reference to the second material + reverse (bool, optional): If true it will also swap the polygons assigned to mat2 to mat1. Defaults to False. + swap_slots (bool, optional): If true it will also swap the material slots. Defaults to False. + + Retruns: + Tuple[bpy.types.Material, bpy.types.Material]: The swapped materials + + Raises: + MaterialNotFoundError: If one of the materials is not found + """ + mesh = cast(bpy.types.Mesh, mesh_object.data) + try: + # Try to find the materials + mat1 = mesh.materials[mat1_ref] + mat2 = mesh.materials[mat2_ref] + if None in (mat1, mat2): + raise MaterialNotFoundError() + 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) + # Swap polygons + for poly in mesh.polygons: + if poly.material_index == mat1_idx: + poly.material_index = mat2_idx + elif reverse and poly.material_index == mat2_idx: + poly.material_index = mat1_idx + # Swap slots if specified + if swap_slots: + mesh_object.material_slots[mat1_idx].material = mat2 + mesh_object.material_slots[mat2_idx].material = mat1 + return mat1, mat2 + + @staticmethod + def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]): + """ + This method will fix the material order. Which is lost after joining meshes. + """ + materials = cast(bpy.types.Mesh, meshObj.data).materials + for new_idx, mat in enumerate(material_names): + # Get the material that is currently on this index + other_mat = materials[new_idx] + if other_mat.name == mat: + continue # This is already in place + FnMaterial.swap_materials(meshObj, mat, new_idx, reverse=True, swap_slots=True) + + @property + def material_id(self): + mmd_mat = 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 + return mmd_mat.material_id + + @property + def material(self): + return self.__material + + def __same_image_file(self, image, filepath): + if image and image.source == "FILE": + img_filepath = bpy.path.abspath(image.filepath) + if img_filepath == filepath: + return True + try: + return os.path.samefile(img_filepath, filepath) + except: + pass + return False + + def _load_image(self, filepath): + img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None) + if img is None: + try: + img = bpy.data.images.load(filepath) + logger.debug(f"Loaded image from {filepath}") + except: + 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 + # For Blender 4.4+ + if img.depth == 32 and img.file_format != "BMP": + img.alpha_mode = "CHANNEL_PACKED" + else: + img.alpha_mode = "NONE" + return img + + def update_toon_texture(self): + if self._nodes_are_readonly: + return + mmd_mat = self.__material.mmd_material + if mmd_mat.is_shared_toon_texture: + # Get shared toon folder from preferences + context = bpy.context + addon_prefs = context.preferences.addons.get("avatar_toolkit", None) + if addon_prefs: + shared_toon_folder = addon_prefs.preferences.shared_toon_folder + else: + shared_toon_folder = "" + toon_path = os.path.join(shared_toon_folder, f"toon{mmd_mat.shared_toon_texture + 1:02d}.bmp") + self.create_toon_texture(bpy.path.resolve_ncase(path=toon_path)) + elif mmd_mat.toon_texture != "": + self.create_toon_texture(mmd_mat.toon_texture) + else: + self.remove_toon_texture() + + def _mix_diffuse_and_ambient(self, mmd_mat): + r, g, b = mmd_mat.diffuse_color + ar, ag, ab = mmd_mat.ambient_color + return [min(1.0, 0.5 * r + ar), min(1.0, 0.5 * g + ag), min(1.0, 0.5 * b + ab)] + + def update_drop_shadow(self): + pass + + def update_enabled_toon_edge(self): + if self._nodes_are_readonly: + return + self.update_edge_color() + + def update_edge_color(self): + if self._nodes_are_readonly: + return + mat = self.__material + mmd_mat = 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)),) + + # For Blender 4.4+ + if hasattr(mat, "line_color"): # freestyle line color + mat.line_color = line_color + + mat_edge = bpy.data.materials.get("mmd_edge." + mat.name, None) + if mat_edge: + mat_edge.mmd_material.edge_color = line_color + + if mat.name.startswith("mmd_edge.") and mat.node_tree: + mmd_mat.ambient_color, mmd_mat.alpha = color, alpha + node_shader = mat.node_tree.nodes.get("mmd_edge_preview", None) + if node_shader and "Color" in node_shader.inputs: + 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 + + def update_edge_weight(self): + pass + + def get_texture(self): + return self.__get_texture_node("mmd_base_tex") + + def create_texture(self, filepath): + texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1)) + return texture + + def remove_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_base_tex") + + def get_sphere_texture(self): + return self.__get_texture_node("mmd_sphere_tex") + + def use_sphere_texture(self, use_sphere, obj=None): + if self._nodes_are_readonly: + return + if use_sphere: + self.update_sphere_texture_type(obj) + else: + self.__update_shader_input("Sphere Tex Fac", 0) + + def create_sphere_texture(self, filepath, obj=None): + texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2)) + self.update_sphere_texture_type(obj) + return texture + + def update_sphere_texture_type(self, obj=None): + if self._nodes_are_readonly: + return + sphere_texture_type = int(self.material.mmd_material.sphere_texture_type) + is_sph_add = sphere_texture_type == 2 + + if sphere_texture_type not in (1, 2, 3): + self.__update_shader_input("Sphere Tex Fac", 0) + else: + self.__update_shader_input("Sphere Tex Fac", 1) + self.__update_shader_input("Sphere Mul/Add", is_sph_add) + self.__update_shader_input("Sphere Tex", (0, 0, 0, 1) if is_sph_add else (1, 1, 1, 1)) + + texture = self.__get_texture_node("mmd_sphere_tex") + if texture and (not texture.inputs["Vector"].is_linked or texture.inputs["Vector"].links[0].from_node.name == "mmd_tex_uv"): + # For Blender 4.4+ + texture.image.colorspace_settings.name = "Linear Rec.709" if is_sph_add else "sRGB" + + mat = self.material + nodes, links = mat.node_tree.nodes, mat.node_tree.links + if sphere_texture_type == 3: + if obj and obj.type == "MESH" and mat in tuple(obj.data.materials): + uv_layers = (l for l in obj.data.uv_layers if not l.name.startswith("_")) + next(uv_layers, None) # skip base UV + subtex_uv = getattr(next(uv_layers, None), "name", "") + if subtex_uv != "UV1": + logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex') + 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"]) + + def remove_sphere_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_sphere_tex") + + def get_toon_texture(self): + return self.__get_texture_node("mmd_toon_tex") + + def use_toon_texture(self, use_toon): + if self._nodes_are_readonly: + return + self.__update_shader_input("Toon Tex Fac", use_toon) + + def create_toon_texture(self, filepath): + texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5)) + return texture + + def remove_toon_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_toon_tex") + + def __get_texture_node(self, node_name): + mat = self.material + texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) + if isinstance(texture, bpy.types.ShaderNodeTexImage): + return texture + return None + + def __remove_texture_node(self, node_name): + mat = self.material + texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) + if isinstance(texture, bpy.types.ShaderNodeTexImage): + mat.node_tree.nodes.remove(texture) + mat.update_tag() + + def __create_texture_node(self, node_name, filepath, pos): + texture = self.__get_texture_node(node_name) + if texture is None: + from mathutils import Vector + + self.__update_shader_nodes() + nodes = self.material.node_tree.nodes + texture = nodes.new("ShaderNodeTexImage") + texture.label = bpy.path.display_name(node_name) + texture.name = node_name + texture.location = nodes["mmd_shader"].location + Vector((pos[0] * 210, pos[1] * 220)) + texture.image = self._load_image(filepath) + self.__update_shader_nodes() + return texture + + def update_ambient_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + # For Blender 4.4+ + mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) + self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,)) + + def update_diffuse_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + # For Blender 4.4+ + mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) + self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,)) + + def update_alpha(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + + # For Blender 4.4+ + mat.blend_method = "HASHED" + + # Update alpha in diffuse_color + if len(mat.diffuse_color) > 3: + mat.diffuse_color[3] = mmd_mat.alpha + + self.__update_shader_input("Alpha", mmd_mat.alpha) + self.update_self_shadow_map() + + def update_specular_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + mat.specular_color = mmd_mat.specular_color + self.__update_shader_input("Specular Color", mmd_mat.specular_color[:] + (1,)) + + def update_shininess(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + + # For Blender 4.4+ + mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37) + mat.metallic = pow(1 - mat.roughness, 2.7) + + self.__update_shader_input("Reflect", mmd_mat.shininess) + + def update_is_double_sided(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + + # For Blender 4.4+ + mat.use_backface_culling = not mmd_mat.is_double_sided + + self.__update_shader_input("Double Sided", mmd_mat.is_double_sided) + + def update_self_shadow_map(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + cast_shadows = mmd_mat.enabled_self_shadow_map if mmd_mat.alpha > 1e-3 else False + + # For Blender 4.4+ + mat.shadow_method = "HASHED" if cast_shadows else "NONE" + + def update_self_shadow(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + self.__update_shader_input("Self Shadow", mmd_mat.enabled_self_shadow) + + @staticmethod + def convert_to_mmd_material(material, context=bpy.context): + m, mmd_material = material, material.mmd_material + + 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): + if node.type == "TEX_IMAGE": + return node + for node_input in node.inputs: + if not node_input.is_linked: + continue + child = search_tex_image_node(node_input.links[0].from_node) + if child is not None: + return child + return None + + # For Blender 4.4+ + preferred_output_node_target = "EEVEE" + + tex_node = None + for target in [preferred_output_node_target, "ALL"]: + output_node = m.node_tree.get_output_node(target) + if output_node is None: + continue + + if not output_node.inputs[0].is_linked: + continue + + tex_node = search_tex_image_node(output_node.inputs[0].links[0].from_node) + break + + 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: + tex_node.name = "mmd_base_tex" + else: + # Take the Base Color from BSDF if there's no texture + bsdf_node = next((n for n in m.node_tree.nodes if n.type.startswith('BSDF_')), None) + if bsdf_node: + base_color_input = bsdf_node.inputs.get('Base Color') or bsdf_node.inputs.get('Color') + if base_color_input: + 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] + + # For Blender 4.4+ + shadow_method = getattr(m, "shadow_method", None) + + if mmd_material.diffuse_color is None: + mmd_material.diffuse_color = m.diffuse_color[:3] + + # For Blender 4.4+ + if len(m.diffuse_color) > 3: + mmd_material.alpha = m.diffuse_color[3] + + mmd_material.specular_color = m.specular_color + + # For Blender 4.4+ + mmd_material.shininess = pow(1 / max(m.roughness, 0.099), 1 / 0.37) + mmd_material.is_double_sided = not m.use_backface_culling + + if shadow_method: + mmd_material.enabled_self_shadow_map = (shadow_method != "NONE") and mmd_material.alpha > 1e-3 + mmd_material.enabled_self_shadow = shadow_method != "NONE" + + # delete bsdf node if it's there + if m.use_nodes: + nodes_to_remove = [n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED' or n.type.startswith('BSDF_')] + for n in nodes_to_remove: + m.node_tree.nodes.remove(n) + + def __update_shader_input(self, name, val): + mat = self.material + if mat.name.startswith("mmd_"): # skip mmd_edge.* + return + self.__update_shader_nodes() + shader = mat.node_tree.nodes.get("mmd_shader", None) + if shader and name in shader.inputs: + interface_socket = shader.node_tree.interface.items_tree[name] + if hasattr(interface_socket, "min_value"): + val = min(max(val, interface_socket.min_value), interface_socket.max_value) + shader.inputs[name].default_value = val + + def __update_shader_nodes(self): + mat = self.material + if mat.node_tree is None: + 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 + + node_shader = nodes.get("mmd_shader", None) + if node_shader is None: + node_shader = 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 = 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,) + node_shader.inputs.get("Reflect", _Dummy).default_value = mmd_mat.shininess + node_shader.inputs.get("Alpha", _Dummy).default_value = mmd_mat.alpha + node_shader.inputs.get("Double Sided", _Dummy).default_value = mmd_mat.is_double_sided + node_shader.inputs.get("Self Shadow", _Dummy).default_value = mmd_mat.enabled_self_shadow + self.update_sphere_texture_type() + + node_uv = nodes.get("mmd_tex_uv", None) + if node_uv is None: + node_uv = nodes.new("ShaderNodeGroup") + node_uv.name = "mmd_tex_uv" + node_uv.location = node_shader.location + Vector((-5 * 210, -2.5 * 220)) + node_uv.node_tree = self.__get_shader_uv() + + if not (node_shader.outputs["Shader"].is_linked or node_shader.outputs["Color"].is_linked or node_shader.outputs["Alpha"].is_linked): + node_output = next((n for n in nodes if isinstance(n, bpy.types.ShaderNodeOutputMaterial) and n.is_active_output), None) + if node_output is None: + node_output = nodes.new("ShaderNodeOutputMaterial") + node_output.is_active_output = True + node_output.location = node_shader.location + Vector((400, 0)) + links.new(node_shader.outputs["Shader"], node_output.inputs["Surface"]) + + for name_id in ("Base", "Toon", "Sphere"): + texture = self.__get_texture_node("mmd_%s_tex" % name_id.lower()) + if texture: + name_tex_in, name_alpha_in, name_uv_out = (name_id + x for x in (" Tex", " Alpha", " UV")) + if not node_shader.inputs.get(name_tex_in, _Dummy).is_linked: + links.new(texture.outputs["Color"], node_shader.inputs[name_tex_in]) + if not node_shader.inputs.get(name_alpha_in, _Dummy).is_linked: + links.new(texture.outputs["Alpha"], node_shader.inputs[name_alpha_in]) + if not texture.inputs["Vector"].is_linked: + links.new(node_uv.outputs[name_uv_out], texture.inputs["Vector"]) + + def __get_shader_uv(self): + group_name = "MMDTexUV" + 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 + + ng = _NodeGroupUtils(shader) + + ############################################################################ + _node_output = ng.new_node("NodeGroupOutput", (6, 0)) + + tex_coord = ng.new_node("ShaderNodeTexCoord", (0, 0)) + + tex_coord1 = ng.new_node("ShaderNodeUVMap", (4, -2)) + tex_coord1.uv_map = "UV1" + + vec_trans = ng.new_node("ShaderNodeVectorTransform", (1, -1)) + vec_trans.vector_type = "NORMAL" + vec_trans.convert_from = "OBJECT" + vec_trans.convert_to = "CAMERA" + + node_vector = ng.new_node("ShaderNodeMapping", (2, -1)) + node_vector.vector_type = "POINT" + node_vector.inputs["Location"].default_value = (0.5, 0.5, 0.0) + node_vector.inputs["Scale"].default_value = (0.5, 0.5, 1.0) + + links = ng.links + links.new(tex_coord.outputs["Normal"], vec_trans.inputs["Vector"]) + links.new(vec_trans.outputs["Vector"], node_vector.inputs["Vector"]) + + ng.new_output_socket("Base UV", tex_coord.outputs["UV"]) + ng.new_output_socket("Toon UV", node_vector.outputs["Vector"]) + ng.new_output_socket("Sphere UV", node_vector.outputs["Vector"]) + ng.new_output_socket("SubTex UV", tex_coord1.outputs["UV"]) + + return shader + + def __get_shader(self): + group_name = "MMDShaderDev" + 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 + + ng = _NodeGroupUtils(shader) + + ############################################################################ + node_input = ng.new_node("NodeGroupInput", (-5, -1)) + _node_output = ng.new_node("NodeGroupOutput", (11, 1)) + + node_diffuse = ng.new_mix_node("ADD", (-3, 4), fac=0.6) + node_diffuse.use_clamp = True + + node_tex = ng.new_mix_node("MULTIPLY", (-2, 3.5)) + node_toon = ng.new_mix_node("MULTIPLY", (-1, 3)) + node_sph = ng.new_mix_node("MULTIPLY", (0, 2.5)) + node_spa = ng.new_mix_node("ADD", (0, 1.5)) + node_sphere = ng.new_mix_node("MIX", (1, 1)) + + node_geo = ng.new_node("ShaderNodeNewGeometry", (6, 3.5)) + node_invert = ng.new_math_node("LESS_THAN", (7, 3)) + node_cull = ng.new_math_node("MAXIMUM", (8, 2.5)) + node_alpha = ng.new_math_node("MINIMUM", (9, 2)) + node_alpha.use_clamp = True + node_alpha_tex = ng.new_math_node("MULTIPLY", (-1, -2)) + node_alpha_toon = ng.new_math_node("MULTIPLY", (0, -2.5)) + node_alpha_sph = ng.new_math_node("MULTIPLY", (1, -3)) + + node_reflect = ng.new_math_node("DIVIDE", (7, -1.5), value1=1) + node_reflect.use_clamp = True + + shader_diffuse = ng.new_node("ShaderNodeBsdfDiffuse", (8, 0)) + shader_glossy = ng.new_node("ShaderNodeBsdfAnisotropic", (8, -1)) + shader_base_mix = ng.new_node("ShaderNodeMixShader", (9, 0)) + shader_base_mix.inputs["Fac"].default_value = 0.02 + shader_trans = ng.new_node("ShaderNodeBsdfTransparent", (9, 1)) + shader_alpha_mix = ng.new_node("ShaderNodeMixShader", (10, 1)) + + links = ng.links + links.new(node_reflect.outputs["Value"], shader_glossy.inputs["Roughness"]) + links.new(shader_diffuse.outputs["BSDF"], shader_base_mix.inputs[1]) + links.new(shader_glossy.outputs["BSDF"], shader_base_mix.inputs[2]) + + links.new(node_diffuse.outputs["Color"], node_tex.inputs["Color1"]) + links.new(node_tex.outputs["Color"], node_toon.inputs["Color1"]) + links.new(node_toon.outputs["Color"], node_sph.inputs["Color1"]) + links.new(node_toon.outputs["Color"], node_spa.inputs["Color1"]) + links.new(node_sph.outputs["Color"], node_sphere.inputs["Color1"]) + links.new(node_spa.outputs["Color"], node_sphere.inputs["Color2"]) + links.new(node_sphere.outputs["Color"], shader_diffuse.inputs["Color"]) + + links.new(node_geo.outputs["Backfacing"], node_invert.inputs[0]) + links.new(node_invert.outputs["Value"], node_cull.inputs[0]) + links.new(node_cull.outputs["Value"], node_alpha.inputs[0]) + links.new(node_alpha_tex.outputs["Value"], node_alpha_toon.inputs[0]) + links.new(node_alpha_toon.outputs["Value"], node_alpha_sph.inputs[0]) + links.new(node_alpha_sph.outputs["Value"], node_alpha.inputs[1]) + + links.new(node_alpha.outputs["Value"], shader_alpha_mix.inputs["Fac"]) + links.new(shader_trans.outputs["BSDF"], shader_alpha_mix.inputs[1]) + links.new(shader_base_mix.outputs["Shader"], shader_alpha_mix.inputs[2]) + + ############################################################################ + ng.new_input_socket("Ambient Color", node_diffuse.inputs["Color1"], (0.4, 0.4, 0.4, 1)) + ng.new_input_socket("Diffuse Color", node_diffuse.inputs["Color2"], (0.8, 0.8, 0.8, 1)) + # ↓ specular should be disabled by default + ng.new_input_socket("Specular Color", shader_glossy.inputs["Color"], (0.0, 0.0, 0.0, 1)) + ng.new_input_socket("Reflect", node_reflect.inputs[1], 50, min_max=(1, 512)) + ng.new_input_socket("Base Tex Fac", node_tex.inputs["Fac"], 1) + ng.new_input_socket("Base Tex", node_tex.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Toon Tex Fac", node_toon.inputs["Fac"], 1) + ng.new_input_socket("Toon Tex", node_toon.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Sphere Tex Fac", node_sph.inputs["Fac"], 1) + ng.new_input_socket("Sphere Tex", node_sph.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Sphere Mul/Add", node_sphere.inputs["Fac"], 0) + ng.new_input_socket("Double Sided", node_cull.inputs[1], 0, min_max=(0, 1)) + ng.new_input_socket("Alpha", node_alpha_tex.inputs[0], 1, min_max=(0, 1)) + ng.new_input_socket("Base Alpha", node_alpha_tex.inputs[1], 1, min_max=(0, 1)) + ng.new_input_socket("Toon Alpha", node_alpha_toon.inputs[1], 1, min_max=(0, 1)) + ng.new_input_socket("Sphere Alpha", node_alpha_sph.inputs[1], 1, min_max=(0, 1)) + + links.new(node_input.outputs["Sphere Tex Fac"], node_spa.inputs["Fac"]) + links.new(node_input.outputs["Sphere Tex"], node_spa.inputs["Color2"]) + + ng.new_output_socket("Shader", shader_alpha_mix.outputs["Shader"]) + ng.new_output_socket("Color", node_sphere.outputs["Color"]) + ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) + + return shader + + +class MigrationFnMaterial: + @staticmethod + def update_mmd_shader(): + mmd_shader_node_tree = bpy.data.node_groups.get("MMDShaderDev") + if mmd_shader_node_tree is None: + return + + ng = _NodeGroupUtils(mmd_shader_node_tree) + if "Color" in ng.node_output.inputs: + return + + shader_diffuse = [n for n in mmd_shader_node_tree.nodes if n.type == "BSDF_DIFFUSE"][0] + node_sphere = shader_diffuse.inputs["Color"].links[0].from_node + node_output = ng.node_output + shader_alpha_mix = node_output.inputs["Shader"].links[0].from_node + node_alpha = shader_alpha_mix.inputs["Fac"].links[0].from_node + + ng.new_output_socket("Color", node_sphere.outputs["Color"]) + ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) diff --git a/core/mmd/properties/__init__.py b/core/mmd/properties/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mmd/properties/pose_bone.py b/core/mmd/properties/pose_bone.py new file mode 100644 index 0000000..7795325 --- /dev/null +++ b/core/mmd/properties/pose_bone.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +import bpy +from bpy.types import PropertyGroup, Context, PoseBone +from bpy.props import ( + StringProperty, + IntProperty, + BoolProperty, + FloatProperty, + FloatVectorProperty +) + +from ..logging_setup import logger +from ..bone import FnBone + +def _mmd_bone_update_additional_transform(prop, context: Context): + """Update handler for additional transform properties""" + 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(): + FnBone.apply_additional_transformation(prop.id_data) + +def _mmd_bone_update_additional_transform_influence(prop, context: Context): + """Update handler for additional transform influence""" + pose_bone = context.active_pose_bone + if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer(): + FnBone.update_additional_transform_influence(pose_bone) + else: + prop["is_additional_transform_dirty"] = True + +def _mmd_bone_get_additional_transform_bone(prop): + """Getter for additional transform bone property""" + arm = prop.id_data + bone_id = prop.get("additional_transform_bone_id", -1) + if bone_id < 0: + return "" + pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id) + if pose_bone is None: + return "" + return pose_bone.name + +def _mmd_bone_set_additional_transform_bone(prop, value: str): + """Setter for additional transform bone property""" + arm = prop.id_data + prop["is_additional_transform_dirty"] = True + if value not in arm.pose.bones.keys(): + prop["additional_transform_bone_id"] = -1 + return + pose_bone = arm.pose.bones[value] + prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) + +def _pose_bone_update_mmd_ik_toggle(prop: PoseBone, _context): + """Update handler for IK toggle property""" + v = prop.mmd_ik_toggle + armature_object = prop.id_data + for b in armature_object.pose.bones: + for c in b.constraints: + if c.type == "IK" and c.subtarget == prop.name: + logger.debug('Updating IK constraint %s on bone %s', c.name, b.name) + c.influence = v + b_chain = b if c.use_tail else b.parent + for chain_bone in ([b_chain] + b_chain.parent_recursive)[:c.chain_count]: + limit_c = next((c for c in chain_bone.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if limit_c: + limit_c.influence = v + +class MMDBone(PropertyGroup): + """Property group for MMD bone properties""" + name_j: StringProperty( + name="Name", + description="Japanese Name", + default="", + ) + + name_e: StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + bone_id: IntProperty( + name="Bone ID", + description="Unique ID for the reference of bone morph and rotate+/move+", + default=-1, + min=-1, + ) + + transform_order: IntProperty( + name="Transform Order", + description="Deformation tier", + min=0, + max=100, + soft_max=7, + ) + + is_controllable: BoolProperty( + name="Controllable", + description="Is controllable", + default=True, + ) + + transform_after_dynamics: BoolProperty( + name="After Dynamics", + description="After physics", + default=False, + ) + + enabled_fixed_axis: BoolProperty( + name="Fixed Axis", + description="Use fixed axis", + default=False, + ) + + fixed_axis: FloatVectorProperty( + name="Fixed Axis", + description="Fixed axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, + default=[0, 0, 0], + ) + + enabled_local_axes: BoolProperty( + name="Local Axes", + description="Use local axes", + default=False, + ) + + local_axis_x: FloatVectorProperty( + name="Local X-Axis", + description="Local x-axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, + default=[1, 0, 0], + ) + + local_axis_z: FloatVectorProperty( + name="Local Z-Axis", + description="Local z-axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, + default=[0, 0, 1], + ) + + is_tip: BoolProperty( + name="Tip Bone", + description="Is zero length bone", + default=False, + ) + + ik_rotation_constraint: FloatProperty( + name="IK Rotation Constraint", + description="The unit angle of IK", + subtype="ANGLE", + soft_min=0, + soft_max=4, + default=1, + ) + + has_additional_rotation: BoolProperty( + name="Additional Rotation", + description="Additional rotation", + default=False, + update=_mmd_bone_update_additional_transform, + ) + + has_additional_location: BoolProperty( + name="Additional Location", + description="Additional location", + default=False, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_bone: StringProperty( + name="Additional Transform Bone", + description="Additional transform bone", + set=_mmd_bone_set_additional_transform_bone, + get=_mmd_bone_get_additional_transform_bone, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_bone_id: IntProperty( + name="Additional Transform Bone ID", + default=-1, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_influence: FloatProperty( + name="Additional Transform Influence", + description="Additional transform influence", + default=1, + soft_min=-1, + soft_max=1, + update=_mmd_bone_update_additional_transform_influence, + ) + + is_additional_transform_dirty: BoolProperty( + name="", + default=True + ) + + def is_id_unique(self): + """Check if the bone ID is unique""" + 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) + + +def register(): + """Register MMD bone properties""" + logger.info("Registering MMD bone properties") + bpy.utils.register_class(MMDBone) + + # Add properties to PoseBone + bpy.types.PoseBone.mmd_bone = bpy.props.PointerProperty(type=MMDBone) + bpy.types.PoseBone.is_mmd_shadow_bone = bpy.props.BoolProperty( + name="is_mmd_shadow_bone", + default=False + ) + bpy.types.PoseBone.mmd_shadow_bone_type = bpy.props.StringProperty( + name="mmd_shadow_bone_type" + ) + bpy.types.PoseBone.mmd_ik_toggle = bpy.props.BoolProperty( + name="MMD IK Toggle", + description="MMD IK toggle is used to import/export animation of IK on-off", + update=_pose_bone_update_mmd_ik_toggle, + default=True, + ) + + +def unregister(): + """Unregister MMD bone properties""" + logger.info("Unregistering MMD bone properties") + + # Remove properties from PoseBone + 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 + + bpy.utils.unregister_class(MMDBone) \ No newline at end of file diff --git a/core/mmd/properties/root.py b/core/mmd/properties/root.py new file mode 100644 index 0000000..6423a1e --- /dev/null +++ b/core/mmd/properties/root.py @@ -0,0 +1,582 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. +# All credit goes to the original authors. +# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. +# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. +# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ + +"""Properties for MMD model root object""" + +import bpy + +from .. import utils +from ..bpyutils import FnContext +from ..core.material import FnMaterial +from ..core.model import FnModel +from ..core.sdef import FnSDEF +from . import patch_library_overridable +from .morph import BoneMorph, GroupMorph, MaterialMorph, UVMorph, VertexMorph +from .translations import MMDTranslation + + +def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1): + d = constraint.driver_add(path, index) + variables = d.driver.variables + for x in variables: + variables.remove(x) + return d.driver, variables + + +def __add_single_prop(variables, id_obj, data_path, prefix): + var = variables.new() + var.name = prefix + str(len(variables)) + var.type = "SINGLE_PROP" + target = var.targets[0] + target.id_type = "OBJECT" + target.id = id_obj + target.data_path = data_path + return var + + +def _toggleUsePropertyDriver(self: "MMDRoot", _context): + root_object: bpy.types.Object = self.id_data + armature_object = FnModel.find_armature_object(root_object) + + if armature_object is None: + ik_map = {} + 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: + 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 + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + 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 + for i in FnModel.iterate_mesh_objects(root_object): + for prop_hide in ("hide_viewport", "hide_render"): + driver, variables = __driver_variables(i, prop_hide) + driver.expression = "not %s" % __add_single_prop(variables, root_object, "mmd_root.show_meshes", "show").name + else: + for ik, (b, c) in ik_map.items(): + c.driver_remove("influence") + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + c.driver_remove("influence") + for i in FnModel.iterate_mesh_objects(root_object): + for prop_hide in ("hide_viewport", "hide_render"): + i.driver_remove(prop_hide) + + +# =========================================== +# Callback functions +# =========================================== + + +def _toggleUseToonTexture(self: "MMDRoot", _context): + use_toon = self.use_toon_texture + 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): + use_sphere = self.use_sphere_texture + 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): + mute_sdef = not self.use_sdef + 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): + root = self.id_data + hide = not self.show_meshes + for i in FnModel.iterate_mesh_objects(self.id_data): + i.hide_set(hide) + i.hide_render = hide + if hide and context.active_object is None: + FnContext.set_active_object(context, root) + + +def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context): + root = self.id_data + hide = not self.show_rigid_bodies + 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): + root_object = self.id_data + hide = not self.show_joints + 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): + root_object: bpy.types.Object = self.id_data + hide = not self.show_temporary_objects + with FnContext.temp_override_active_layer_collection(context, root_object): + for i in FnModel.iterate_temporary_objects(root_object): + i.hide_set(hide) + if hide and context.active_object is None: + FnContext.set_active_object(context, root_object) + + +def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context): + root = self.id_data + show_names = root.mmd_root.show_names_of_rigid_bodies + for i in FnModel.iterate_rigid_body_objects(root): + i.show_name = show_names + + +def _toggleShowNamesOfJoints(self: "MMDRoot", _context): + root = self.id_data + show_names = root.mmd_root.show_names_of_joints + for i in FnModel.iterate_joint_objects(root): + i.show_name = show_names + + +def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool): + root = prop.id_data + arm = FnModel.find_armature_object(root) + if arm is None: + return + if not v and bpy.context.active_object == arm: + FnContext.set_active_object(bpy.context, root) + arm.hide_set(not v) + + +def _getVisibilityOfMMDRigArmature(prop: "MMDRoot"): + if prop.id_data.mmd_type != "ROOT": + return False + arm = FnModel.find_armature_object(prop.id_data) + return arm and not arm.hide_get() + + +def _setActiveRigidbodyObject(prop: "MMDRoot", v: int): + obj = FnContext.get_scene_objects(bpy.context)[v] + if FnModel.is_rigid_body_object(obj): + FnContext.set_active_and_select_single_object(bpy.context, obj) + prop["active_rigidbody_object_index"] = v + + +def _getActiveRigidbodyObject(prop: "MMDRoot"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_rigid_body_object(active_obj): + prop["active_rigidbody_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_rigidbody_object_index", 0) + + +def _setActiveJointObject(prop: "MMDRoot", v: int): + obj = FnContext.get_scene_objects(bpy.context)[v] + if FnModel.is_joint_object(obj): + FnContext.set_active_and_select_single_object(bpy.context, obj) + prop["active_joint_object_index"] = v + + +def _getActiveJointObject(prop: "MMDRoot"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_joint_object(active_obj): + prop["active_joint_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_joint_object_index", 0) + + +def _setActiveMorph(prop: "MMDRoot", v: bool): + if "active_morph_indices" not in prop: + prop["active_morph_indices"] = [0] * 5 + prop["active_morph_indices"][prop.get("active_morph_type", 3)] = v + + +def _getActiveMorph(prop: "MMDRoot"): + 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): + 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"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_mesh_object(active_obj): + prop["active_mesh_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_mesh_index", -1) + + +# =========================================== +# Property classes +# =========================================== + + +class MMDDisplayItem(bpy.types.PropertyGroup): + """PMX 表示項目(表示枠内の1項目)""" + + type: bpy.props.EnumProperty( + name="Type", + description="Select item type", + items=[ + ("BONE", "Bone", "", 1), + ("MORPH", "Morph", "", 2), + ], + ) + + morph_type: bpy.props.EnumProperty( + name="Morph Type", + description="Select morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + + +class MMDDisplayItemFrame(bpy.types.PropertyGroup): + """PMX 表示枠 + + PMXファイル内では表示枠がリストで格納されています。 + """ + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + # 特殊枠フラグ + # 特殊枠はファイル仕様上の固定枠(削除、リネーム不可) + is_special: bpy.props.BoolProperty( + name="Special", + description="Is special", + default=False, + ) + + # 表示項目のリスト + data: bpy.props.CollectionProperty( + name="Display Items", + type=MMDDisplayItem, + ) + + # 現在アクティブな項目のインデックス + active_item: bpy.props.IntProperty( + name="Active Display Item", + min=0, + default=0, + ) + + +class MMDRoot(bpy.types.PropertyGroup): + """MMDモデルデータ + + モデルルート用に作成されたEmtpyオブジェクトで使用します + """ + + name: bpy.props.StringProperty( + name="Name", + description="The name of the MMD model", + default="", + ) + + name_e: bpy.props.StringProperty( + name="Name (English)", + description="The english name of the MMD model", + default="", + ) + + comment_text: bpy.props.StringProperty( + name="Comment", + description="The text datablock of the comment", + default="", + ) + + comment_e_text: bpy.props.StringProperty( + name="Comment (English)", + description="The text datablock of the english comment", + default="", + ) + + ik_loop_factor: bpy.props.IntProperty( + name="MMD IK Loop Factor", + description="Scaling factor of MMD IK loop", + min=1, + soft_max=10, + max=100, + default=1, + ) + + # TODO: Replace to driver for NLA + show_meshes: bpy.props.BoolProperty( + name="Show Meshes", + description="Show all meshes of the MMD model", + # get=_show_meshes_get, + # set=_show_meshes_set, + update=_toggleVisibilityOfMeshes, + default=True, + ) + + show_rigid_bodies: bpy.props.BoolProperty( + name="Show Rigid Bodies", + description="Show all rigid bodies of the MMD model", + update=_toggleVisibilityOfRigidBodies, + ) + + show_joints: bpy.props.BoolProperty( + name="Show Joints", + description="Show all joints of the MMD model", + update=_toggleVisibilityOfJoints, + ) + + show_temporary_objects: bpy.props.BoolProperty( + name="Show Temps", + description="Show all temporary objects of the MMD model", + update=_toggleVisibilityOfTemporaryObjects, + ) + + show_armature: bpy.props.BoolProperty( + name="Show Armature", + description="Show the armature object of the MMD model", + get=_getVisibilityOfMMDRigArmature, + set=_setVisibilityOfMMDRigArmature, + ) + + show_names_of_rigid_bodies: bpy.props.BoolProperty( + name="Show Rigid Body Names", + description="Show rigid body names", + update=_toggleShowNamesOfRigidBodies, + ) + + show_names_of_joints: bpy.props.BoolProperty( + name="Show Joint Names", + description="Show joint names", + update=_toggleShowNamesOfJoints, + ) + + use_toon_texture: bpy.props.BoolProperty( + name="Use Toon Texture", + description="Use toon texture", + update=_toggleUseToonTexture, + default=True, + ) + + use_sphere_texture: bpy.props.BoolProperty( + name="Use Sphere Texture", + description="Use sphere texture", + update=_toggleUseSphereTexture, + default=True, + ) + + use_sdef: bpy.props.BoolProperty( + name="Use SDEF", + description="Use SDEF", + update=_toggleUseSDEF, + default=True, + ) + + use_property_driver: bpy.props.BoolProperty( + name="Use Property Driver", + description="Setup drivers for MMD property animation (Visibility and IK toggles)", + update=_toggleUsePropertyDriver, + default=False, + ) + + is_built: bpy.props.BoolProperty( + name="Is Built", + ) + + active_rigidbody_index: bpy.props.IntProperty( + name="Active Rigidbody Index", + min=0, + get=_getActiveRigidbodyObject, + set=_setActiveRigidbodyObject, + ) + + active_joint_index: bpy.props.IntProperty( + name="Active Joint Index", + min=0, + get=_getActiveJointObject, + set=_setActiveJointObject, + ) + + # ************************* + # Display Items + # ************************* + display_item_frames: bpy.props.CollectionProperty( + name="Display Frames", + type=MMDDisplayItemFrame, + ) + + active_display_item_frame: bpy.props.IntProperty( + name="Active Display Item Frame", + min=0, + default=0, + ) + + # ************************* + # Morph + # ************************* + material_morphs: bpy.props.CollectionProperty( + name="Material Morphs", + type=MaterialMorph, + ) + uv_morphs: bpy.props.CollectionProperty( + name="UV Morphs", + type=UVMorph, + ) + bone_morphs: bpy.props.CollectionProperty( + name="Bone Morphs", + type=BoneMorph, + ) + vertex_morphs: bpy.props.CollectionProperty(name="Vertex Morphs", type=VertexMorph) + group_morphs: bpy.props.CollectionProperty( + name="Group Morphs", + type=GroupMorph, + ) + active_morph_type: bpy.props.EnumProperty( + name="Active Morph Type", + description="Select current morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + active_morph: bpy.props.IntProperty( + name="Active Morph", + min=0, + set=_setActiveMorph, + get=_getActiveMorph, + ) + morph_panel_show_settings: bpy.props.BoolProperty( + name="Morph Panel Show Settings", + description="Show Morph Settings", + default=True, + ) + active_mesh_index: bpy.props.IntProperty( + name="Active Mesh", + min=0, + set=_setActiveMeshObject, + get=_getActiveMeshObject, + ) + + # ************************* + # Translation + # ************************* + translation: bpy.props.PointerProperty( + name="Translation", + type=MMDTranslation, + ) + + @staticmethod + def __get_select(prop: bpy.types.Object) -> bool: + # TODO: Object.select is deprecated since v4.0.0, use Object.select_get() method instead + # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") + return prop.select_get() + + @staticmethod + def __set_select(prop: bpy.types.Object, value: bool) -> None: + # TODO: Object.select is deprecated since v4.0.0, use Object.select_set() method instead + # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") + prop.select_set(value) + + @staticmethod + def __get_hide(prop: bpy.types.Object) -> bool: + # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_get() method instead + # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") + return prop.hide_get() + + @staticmethod + def __set_hide(prop: bpy.types.Object, value: bool) -> None: + # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_set() method instead + # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") + prop.hide_set(value) + if prop.hide_viewport != value: + prop.hide_viewport = value + + @staticmethod + def register(): + bpy.types.Object.mmd_type = patch_library_overridable( + bpy.props.EnumProperty( + name="Type", + description="Internal MMD type of this object (DO NOT CHANGE IT DIRECTLY)", + default="NONE", + items=[ + ("NONE", "None", "", 1), + ("ROOT", "Root", "", 2), + ("RIGID_GRP_OBJ", "Rigid Body Grp Empty", "", 3), + ("JOINT_GRP_OBJ", "Joint Grp Empty", "", 4), + ("TEMPORARY_GRP_OBJ", "Temporary Grp Empty", "", 5), + ("PLACEHOLDER", "Place Holder", "", 6), + ("CAMERA", "Camera", "", 21), + ("JOINT", "Joint", "", 22), + ("RIGID_BODY", "Rigid body", "", 23), + ("LIGHT", "Light", "", 24), + ("TRACK_TARGET", "Track Target", "", 51), + ("NON_COLLISION_CONSTRAINT", "Non Collision Constraint", "", 52), + ("SPRING_CONSTRAINT", "Spring Constraint", "", 53), + ("SPRING_GOAL", "Spring Goal", "", 54), + ], + ) + ) + bpy.types.Object.mmd_root = patch_library_overridable(bpy.props.PointerProperty(type=MMDRoot)) + + bpy.types.Object.select = patch_library_overridable( + bpy.props.BoolProperty( + get=MMDRoot.__get_select, + set=MMDRoot.__set_select, + options={ + "SKIP_SAVE", + "ANIMATABLE", + "LIBRARY_EDITABLE", + }, + ) + ) + bpy.types.Object.hide = patch_library_overridable( + bpy.props.BoolProperty( + get=MMDRoot.__get_hide, + set=MMDRoot.__set_hide, + options={ + "SKIP_SAVE", + "ANIMATABLE", + "LIBRARY_EDITABLE", + }, + ) + ) + + @staticmethod + def unregister(): + del bpy.types.Object.hide + del bpy.types.Object.select + del bpy.types.Object.mmd_root + del bpy.types.Object.mmd_type \ No newline at end of file From 6e06f7317496d2994420424a6125a65c80863377 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 3 Apr 2025 15:56:58 +0100 Subject: [PATCH 10/32] Readme update --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 565bab4..2208909 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,18 @@ ## Avatar Toolkit is in Alpha, There will be issues, please ensure you report them!. If using a Alpha plugin isn't your fancy you can find Cats Blender Plugin [HERE](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-)! #### Avatar Toolkit is in Alpha and will contain issues, please ensure you report them! -A new modern tool designed to shorten steps needed to import and optimize models into VRChat, Resonite and other similar games. +Avatar Toolkit is a modern, Blender addon designed to streamline the process of preparing 3D avatars for virtual platforms including VRChat, ChilloutVR, Resonite, and other similar applications. -With the Avatar Toolkit it only takes a few minutes to upload your model into VRChat, Resonite and other similar games. +## What is Avatar Toolkit? +Avatar Toolkit simplifies the workflow for avatar creation and optimization by providing an all-in-one solution that: +- Automates complex optimization processes like mesh joining and vertex merging. +- Provides advanced tools for eye tracking setup and viseme configuration. +- Offers specialized armature utilities including bone name conversion for different platforms. +- Includes performance-focused optimization tools so you can optimize your avatar for platforms like VRChat and ChilloutVR. -Join the Neoneko Discord here: https://discord.catsblenderplugin.xyz +The addon is built with a focus on user experience, reducing the number of steps needed to prepare avatars while offering powerful customization options for advanced users. Avatar Toolkit aims to be a complete replacement for Cats Blender Plugin and its unofficial variants, with a modern codebase designed specifically for current Blender versions and minimal dependencies on third-party plugins. + +Join the Neoneko Discord here: https://discord.nenoneko.xyz Need a more stable toolset while Avatar Toolkit is in Alpha? Then please use Blender 4.x and use our Unofficial Cats Blender Plugin which you can find [here](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-). From d1912d2dba1e5f80e492fd8b47c9f0936554901e Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 3 Apr 2025 15:59:54 +0100 Subject: [PATCH 11/32] Fix url for discord --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2208909..7b8f1ea 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Avatar Toolkit simplifies the workflow for avatar creation and optimization by p The addon is built with a focus on user experience, reducing the number of steps needed to prepare avatars while offering powerful customization options for advanced users. Avatar Toolkit aims to be a complete replacement for Cats Blender Plugin and its unofficial variants, with a modern codebase designed specifically for current Blender versions and minimal dependencies on third-party plugins. -Join the Neoneko Discord here: https://discord.nenoneko.xyz +Join the Neoneko Discord here: https://discord.neoneko.xyz Need a more stable toolset while Avatar Toolkit is in Alpha? Then please use Blender 4.x and use our Unofficial Cats Blender Plugin which you can find [here](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-). From 036e260dd68bf7c1f20ad2d5c33201f4599b9385 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 3 Apr 2025 19:12:55 -0400 Subject: [PATCH 12/32] Vastly improve Merge Doubles - removed advanced merge doubles, it just does advanced by default - same behavior as advanced was before, but now completes the task in under a second. Thanks to the power of BMesh! - Labels now reflect this change --- functions/optimization/remove_doubles.py | 280 ++++++----------------- resources/translations/en_US.json | 6 +- ui/optimization_panel.py | 3 +- 3 files changed, 72 insertions(+), 217 deletions(-) diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index e5c20e5..f41e3ef 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -1,3 +1,4 @@ +import traceback import bpy import numpy as np from typing import List, TypedDict, Any, Literal, TypeAlias, cast @@ -9,6 +10,8 @@ from ...core.common import ( get_all_meshes, ) from ...core.armature_validation import validate_armature +import bmesh +import mathutils # Constants MERGE_ITERATION_COUNT = 20 @@ -19,83 +22,38 @@ ModalReturnType: TypeAlias = Literal['RUNNING_MODAL', 'FINISHED', 'CANCELLED'] class MeshEntry(TypedDict): mesh: Object - shapekeys: list[str] - vertices: int - cur_vertex_pass: int + shapekeys: list[bpy.types.Object] -def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str) -> Object: +def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str = "") -> Object: """Creates a duplicate mesh object for merge testing""" - context.view_layer.objects.active = mesh + + if(shapekey_name != ""): + mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(shapekey_name) + bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') mesh.select_set(True) + context.view_layer.objects.active = mesh bpy.ops.object.duplicate() - bpy.ops.object.shape_key_move(type='TOP') + if(shapekey_name != ""): + bpy.ops.object.shape_key_move(type='TOP') + bpy.ops.object.shape_key_remove(all=True,apply_mix=False) duplicate = context.view_layer.objects.active - duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" + if(shapekey_name != ""): + duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" + + else: + duplicate.name = f"object_is_{mesh.name}" return duplicate -def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[int, Any], current_vertex: int) -> list[int]: - """Process vertex merging and return merged vertex indices""" - merged_vertices = [] - i, j = 0, 0 - - while i < len(vertices_original): - if j + 1 > len(mesh_data.vertices): - merged_vertices.append(i) - j = j - 1 - elif mesh_data.vertices[j].co.xyz != vertices_original[i]: - merged_vertices.append(i) - j = j - 1 - elif vertices_original[i] == vertices_original[current_vertex]: - merged_vertices.append(i) - i, j = i + 1, j + 1 - - return merged_vertices - -def vertex_moves(mesh_data: bpy.types.Mesh, vertex: int) -> bool: - - for shapekey in mesh_data.shape_keys.key_blocks: - data: bpy.types.ShapeKey = shapekey - - if data.points[vertex].co.xyz != mesh_data.vertices[vertex].co.xyz: - return True - - return False - -def merge_vertex_at_index(mesh_data: bpy.types.Mesh, index: int, distance: float): - - select_target_vertex = [False]*len(mesh_data.vertices) - select_target_vertex[index] = True - - bpy.ops.object.mode_set(mode='OBJECT') - mesh_data.vertices.foreach_set("select",select_target_vertex) - bpy.ops.object.mode_set(mode='EDIT') - for _ in range(0,20): #for some reason, if using merge to unselected on a vertex, the vertex will only merge to 1 other vertex. so we gotta spam it to fix it. - bpy.ops.mesh.remove_doubles(threshold=distance, use_unselected=True, use_sharp_edge_from_normals=False) +def select_obj(context: Context, obj: Object, target_mode='OBJECT'): bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True) + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode=target_mode) -class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator): - bl_idname = "avatar_toolkit.remove_doubles_advanced" - bl_label = t("Optimization.remove_doubles_advanced") - bl_description = t("Optimization.remove_doubles_advanced_desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - """Check if the operator can be executed""" - armature = get_active_armature(context) - if not armature: - return False - valid, _, _ = validate_armature(armature) - return valid - - def execute(self, context: Context) -> set[str]: - """Execute the advanced remove doubles operator""" - context.scene.avatar_toolkit.remove_doubles_advanced = True - bpy.ops.avatar_toolkit.remove_doubles('INVOKE_DEFAULT') - return {'RUNNING_MODAL'} class AvatarToolkit_OT_RemoveDoubles(Operator): bl_idname = "avatar_toolkit.remove_doubles" @@ -104,7 +62,7 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): bl_options = {'REGISTER', 'UNDO'} objects_to_do: list[MeshEntry] = [] - + merge_distance: bpy.props.FloatProperty(name=t("Optimization.merge_distance"), description=t("Optimization.merge_distance_desc"), default=.001) @classmethod def poll(cls, context: Context) -> bool: """Check if the operator can be executed""" @@ -117,27 +75,27 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): def draw(self, context: Context) -> None: """Draw the operator's UI""" layout = self.layout - layout.prop(context.scene.avatar_toolkit, "remove_doubles_merge_distance") - layout.label(text=t("Optimization.remove_doubles_warning")) - layout.label(text=t("Optimization.remove_doubles_wait")) + layout.prop(self, "merge_distance") def invoke(self, context: Context, event: Event) -> set[str]: """Initialize the operator""" logger.info("Starting modal execution of merge doubles safely") return context.window_manager.invoke_props_dialog(self) - def setup_mesh_entry(self, mesh: Object) -> MeshEntry: + def setup_mesh_entry(self, context: Context, mesh: Object) -> MeshEntry: """Set up mesh entry data structure""" + #create shapekey objects to merge doubles on. + shapes: list[bpy.types.Object] = [] + if(mesh.data.shape_keys): + for shape in mesh.data.shape_keys.key_blocks: + shapes.append(create_duplicate_for_merge(context,mesh,shape.name)) + else: + shapes.append(create_duplicate_for_merge(context,mesh)) mesh_entry: MeshEntry = { "mesh": mesh, - "shapekeys": [], - "vertices": len(mesh.data.vertices), - "cur_vertex_pass": 0 + "shapekeys": shapes } - - if mesh.data.shape_keys: - mesh_entry["shapekeys"] = [shape.name for shape in mesh.data.shape_keys.key_blocks] - + return mesh_entry def execute(self, context: Context) -> set[str]: @@ -157,7 +115,7 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): for mesh in objects: if mesh.data.name not in [obj["mesh"].data.name for obj in self.objects_to_do]: logger.debug(f"Setting up data for object {mesh.name}") - mesh_entry = self.setup_mesh_entry(mesh) + mesh_entry = self.setup_mesh_entry(context, mesh) self.objects_to_do.append(mesh_entry) context.window_manager.modal_handler_add(self) @@ -167,148 +125,50 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): logger.error(f"Error in execute: {str(e)}") return {'CANCELLED'} - def modify_mesh(self, context: Context, mesh: MeshEntry) -> None: - """Basic mesh modification for simple cases""" - try: - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - mesh_data = mesh["mesh"].data - - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.object.mode_set(mode='OBJECT') - - # Select vertices with different positions in shape keys - for index, point in enumerate(mesh["mesh"].active_shape_key.points): - if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz: - mesh_data.vertices[index].select = True - logger.debug(f"Shapekey has moved vertex at index {index}") - - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - - except Exception as e: - logger.error(f"Error in modify_mesh: {str(e)}") - - def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> int: - """Advanced mesh modification with shape key handling""" - try: - final_merged_vertex_group = [] - initialized_final = False - merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance - - for shapekey_name in mesh_entry["shapekeys"]: - duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name) - vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)} - - - merge_vertex_at_index(duplicate.data, mesh_entry["cur_vertex_pass"], merge_distance) #merge the vertex at our pass to find vertices that would merge to our vertex at this shapekey. - - # Process merging - merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) # find what vertices actually merged. - - if not initialized_final: - final_merged_vertex_group = merged_vertices.copy() - initialized_final = True - else: - final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] # remove vertices that merged from the list if they didn't merge during this shapkey. - bpy.ops.object.delete() - - # Apply final merging - if final_merged_vertex_group: - self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance) # merge all vertices that merged on every shapekey no matter the shapekey during the loop. - - return len(final_merged_vertex_group) - - except Exception as e: - logger.error(f"Error in modify_mesh_advanced: {str(e)}") - return 1 - - def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None: - """Apply final vertex merging operations""" - mesh = mesh_entry["mesh"] - context.view_layer.objects.active = mesh - mesh.select_set(True) - - bpy.ops.object.mode_set(mode='OBJECT') - select_target_group = [False] * len(mesh.data.vertices) - for vertex_index in vertex_group: - select_target_group[vertex_index] = True - - mesh.data.vertices.foreach_set("select", select_target_group) - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) - bpy.ops.object.mode_set(mode='OBJECT') - - def process_simple_mesh(self, context: Context, mesh: MeshEntry, merge_distance: float) -> None: - """Process mesh without shapekeys using simple merge operation""" - logger.debug(f"Processing mesh without shapekeys: {mesh['mesh'].name}") - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - bpy.ops.object.mode_set(mode='EDIT') - mesh["mesh"].data.vertices.foreach_set("select", [False] * len(mesh["mesh"].data.vertices)) - - bpy.ops.mesh.select_all(action="INVERT") - bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - - def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None: - """Complete the mesh processing by performing final merge operations""" - logger.debug("Finishing mesh processing") - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action="INVERT") - bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) - - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - def modal(self, context: Context, event: Event) -> set[ModalReturnType]: """Modal operator execution""" try: - if not self.objects_to_do: + if not self.objects_to_do or len(self.objects_to_do) <= 0: self.report({'INFO'}, t("Optimization.remove_doubles_completed")) logger.info("Finishing modal execution of merge doubles safely") return {'FINISHED'} - - mesh = self.objects_to_do[0] - mesh_data = mesh["mesh"].data - advanced = context.scene.avatar_toolkit.remove_doubles_advanced - merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance - - if len(mesh['shapekeys']) > 0 and not advanced: - shapekeyname = mesh['shapekeys'].pop(0) - mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname) - logger.debug(f"Processing shapekey {shapekeyname}") - self.modify_mesh(context, mesh) + + mesh: MeshEntry = self.objects_to_do.pop(0) + merge_distance: float = self.merge_distance + + + #find which vertices merge on all shapekeys using bmesh, a fast way of doing it - @989onan + final_merged_vertex_group = [i for i in range(0,len(mesh['mesh'].data.vertices))] + for shape in mesh["shapekeys"]: + select_obj(context, shape, target_mode='EDIT') + bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(shape.data) + selected_verts: list[bmesh.types.BMVert] = [vert for vert in bmesh_mesh.verts if vert.select == True] + i: int = 0 + merged_vertices: set[int] = set() + mergers: dict[bmesh.types.BMVert, bmesh.types.BMVert] + for name,mergers in bmesh.ops.find_doubles(bmesh_mesh,verts=selected_verts,dist=merge_distance).items(): + for source_vert,target_vert in mergers.items(): + merged_vertices.add(source_vert.index) + merged_vertices.add(target_vert.index) + + final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] - elif not mesh_data.shape_keys: - self.process_simple_mesh(context, mesh, merge_distance) - self.objects_to_do.pop(0) - - elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: #advanced merging vertex by vertex - if(mesh["cur_vertex_pass"] < 0): #make sure it doesn't go below 0 and explode when advancing backwards from a previous step - mesh["cur_vertex_pass"] = 0 - - if vertex_moves(mesh["mesh"].data, mesh["cur_vertex_pass"]): # do not do advanced merging for vertices that don't move - mesh["cur_vertex_pass"] -= self.modify_mesh_advanced(context, mesh)-2 #advance forward or backwards based on how many vertices actually got merged, changing the list size. - #if above returns 1 (no vertices other than this one being merged to ourselves), advance by 1. else don't advance or go backwards. Makes sure all vertices get merged in the end. - else: - mesh["cur_vertex_pass"] += 1 + select_obj(context, mesh['mesh'], target_mode='EDIT') + data_mesh: bpy.types.Mesh = mesh['mesh'].data + bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(data_mesh) + mergable_on_all_shapes: list[bmesh.types.BMVert] = [vert for vert in bmesh_mesh.verts if vert.index in final_merged_vertex_group] + + mappings: dict[bmesh.types.BMVert,bmesh.types.BMVert] = bmesh.ops.find_doubles(bmesh_mesh,verts=mergable_on_all_shapes,dist=merge_distance)["targetmap"] - elif (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced and len(mesh['shapekeys']) > 0: #after advanced merging has gone past all the moving vertices, now we need to merge non moving vertices. - shapekeyname = mesh['shapekeys'].pop(0) - mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname) - logger.debug(f"Processing shapekey {shapekeyname}") - self.modify_mesh(context, mesh) - else: - self.finish_mesh_processing(context, mesh, advanced, merge_distance) - self.objects_to_do.pop(0) + bmesh.ops.weld_verts(bmesh_mesh,targetmap=mappings) + bmesh.update_edit_mesh(data_mesh, destructive=True) + + + for shape in mesh["shapekeys"]: + bpy.data.objects.remove(shape) return {'RUNNING_MODAL'} except Exception as e: - logger.error(f"Error in modal: {str(e)}") + logger.error(f"Error in modal: {traceback.format_exception(e)}") return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 2c642d6..cb7a600 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -128,9 +128,7 @@ "Optimization.combine_materials": "Combine Materials", "Optimization.combine_materials_desc": "Combine similar materials to reduce draw calls", "Optimization.remove_doubles": "Remove Doubles", - "Optimization.remove_doubles_desc": "Remove duplicate vertices", - "Optimization.remove_doubles_advanced": "Advanced", - "Optimization.remove_doubles_advanced_desc": "Remove duplicate vertices with advanced options", + "Optimization.remove_doubles_desc": "Remove duplicate vertices safely, keeping shapekeys preserved.", "Optimization.join_all_meshes": "Join All", "Optimization.join_all_meshes_desc": "Join all meshes in the scene", "Optimization.join_selected_meshes": "Join Selected", @@ -158,8 +156,6 @@ "Optimization.error.join_selected": "Failed to join selected meshes: {error}", "Optimization.merge_distance": "Merge Distance", "Optimization.merge_distance_desc": "Distance within which vertices will be merged", - "Optimization.remove_doubles_warning": "This process may take a long time", - "Optimization.remove_doubles_wait": "Blender may seem unresponsive during this operation", "Optimization.error.remove_doubles": "Failed to remove doubles: {error}", "Optimization.no_armature": "No armature selected", "Optimization.processing_mesh": "Processing mesh: {name}", diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py index 04eb8dc..cfa1559 100644 --- a/ui/optimization_panel.py +++ b/ui/optimization_panel.py @@ -4,7 +4,7 @@ from bpy.types import Panel, Context, UILayout, Operator from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t from ..functions.optimization.materials_tools import AvatarToolkit_OT_CombineMaterials -from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles,AvatarToolkit_OT_RemoveDoublesAdvanced +from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles from ..functions.optimization.mesh_tools import AvatarToolkit_OT_JoinAllMeshes, AvatarToolkit_OT_JoinSelectedMeshes class AvatarToolKit_PT_OptimizationPanel(Panel): @@ -40,7 +40,6 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): # Remove Doubles Row row: UILayout = col.row(align=True) row.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA') - row.operator(AvatarToolkit_OT_RemoveDoublesAdvanced.bl_idname, icon='PREFERENCES') # Join Meshes Box join_box: UILayout = layout.box() From 046ebfa72d45e44468d5fd92156699abb0c864b2 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 3 Apr 2025 19:44:56 -0400 Subject: [PATCH 13/32] bugfix fix pairs not merging if they would merge on one shapekey but not another --- functions/optimization/remove_doubles.py | 37 +++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index f41e3ef..b4f33aa 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -138,32 +138,49 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): #find which vertices merge on all shapekeys using bmesh, a fast way of doing it - @989onan - final_merged_vertex_group = [i for i in range(0,len(mesh['mesh'].data.vertices))] + #final_merged_vertex_group = [i for i in range(0,len(mesh['mesh'].data.vertices))] + final_merged_vertex_group: dict[set[int],list[int]] = [] for shape in mesh["shapekeys"]: select_obj(context, shape, target_mode='EDIT') bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(shape.data) selected_verts: list[bmesh.types.BMVert] = [vert for vert in bmesh_mesh.verts if vert.select == True] i: int = 0 - merged_vertices: set[int] = set() + merged_vertices: dict[set[int],list[int]] = {} #make a list of sets which act as pairs. the pairs being sets means it doesn't matter if element 0 is at index 1, it is still considered the same pair mergers: dict[bmesh.types.BMVert, bmesh.types.BMVert] for name,mergers in bmesh.ops.find_doubles(bmesh_mesh,verts=selected_verts,dist=merge_distance).items(): for source_vert,target_vert in mergers.items(): - merged_vertices.add(source_vert.index) - merged_vertices.add(target_vert.index) + pair: set[int] = set() + pair.add(source_vert.index) + pair.add(target_vert.index) + frozen_pair = frozenset(pair) + merged_vertices[frozen_pair] = [source_vert.index,target_vert.index] #put the pairs we have found into a list. - final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] - + if(final_merged_vertex_group == []): #populate list if it is empty + final_merged_vertex_group = merged_vertices + new_dict: dict[set[int],list[int]] = {} + + #update our final list, keeping pairs that exist on all shapekeys and not just one. + for key,value in final_merged_vertex_group.items(): + if key in merged_vertices.keys(): + new_dict[key] = value + final_merged_vertex_group = new_dict + + #create an edit mesh and ensure it's vertex table select_obj(context, mesh['mesh'], target_mode='EDIT') data_mesh: bpy.types.Mesh = mesh['mesh'].data + mappings: dict[bmesh.types.BMVert,bmesh.types.BMVert] = {} bmesh_mesh: bmesh.types.BMesh = bmesh.from_edit_mesh(data_mesh) - mergable_on_all_shapes: list[bmesh.types.BMVert] = [vert for vert in bmesh_mesh.verts if vert.index in final_merged_vertex_group] - - mappings: dict[bmesh.types.BMVert,bmesh.types.BMVert] = bmesh.ops.find_doubles(bmesh_mesh,verts=mergable_on_all_shapes,dist=merge_distance)["targetmap"] + bmesh_mesh.verts.ensure_lookup_table() + #turn our pairs into a dictionary, which allows for merging vertices based on the shared pairs. + for key,value in final_merged_vertex_group.items(): + mappings[bmesh_mesh.verts[value[0]]] = bmesh_mesh.verts[value[1]] + + #weld the verts and update the source mesh bmesh.ops.weld_verts(bmesh_mesh,targetmap=mappings) bmesh.update_edit_mesh(data_mesh, destructive=True) - + #delete the shapekey reading meshes. for shape in mesh["shapekeys"]: bpy.data.objects.remove(shape) From 88e88b94a325d0b9ef5aa916c1a670a93c1e99cc Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 3 Apr 2025 20:14:17 -0400 Subject: [PATCH 14/32] hotfix --- functions/optimization/remove_doubles.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index b4f33aa..4240d93 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -26,23 +26,21 @@ class MeshEntry(TypedDict): def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str = "") -> Object: """Creates a duplicate mesh object for merge testing""" - - if(shapekey_name != ""): - mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(shapekey_name) - + bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') mesh.select_set(True) context.view_layer.objects.active = mesh bpy.ops.object.duplicate() - if(shapekey_name != ""): - bpy.ops.object.shape_key_move(type='TOP') - bpy.ops.object.shape_key_remove(all=True,apply_mix=False) - duplicate = context.view_layer.objects.active - if(shapekey_name != ""): - duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" + if(shapekey_name != ""): + for shape in duplicate.data.shape_keys.key_blocks: + shape.value = 0 + duplicate.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(shapekey_name) + duplicate.active_shape_key.value = 1 + bpy.ops.object.shape_key_remove(all=True,apply_mix=True) + duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" else: duplicate.name = f"object_is_{mesh.name}" return duplicate @@ -187,5 +185,6 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): return {'RUNNING_MODAL'} except Exception as e: + print(traceback.format_exception(e)) logger.error(f"Error in modal: {traceback.format_exception(e)}") return {'CANCELLED'} From 6bafc7d7ac4edd4010752c9513c719e79a175181 Mon Sep 17 00:00:00 2001 From: 989onan Date: Sat, 5 Apr 2025 17:54:39 -0400 Subject: [PATCH 15/32] add explode model - Add method that allows for exploding the model into pieces for kit bashing or painting in substance painter. --- functions/tools/general_mesh_tools.py | 95 ++++++++++++++++++++++++++- resources/translations/en_US.json | 6 ++ ui/tools_panel.py | 3 +- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/functions/tools/general_mesh_tools.py b/functions/tools/general_mesh_tools.py index 0ac6d3c..5695f15 100644 --- a/functions/tools/general_mesh_tools.py +++ b/functions/tools/general_mesh_tools.py @@ -1,7 +1,7 @@ import bpy import numpy as np from bpy.types import Operator, Context -from typing import Set +from typing import Set, Literal from ...core.translations import t from ...core.logging_setup import logger from ...core.common import get_active_armature, get_all_meshes @@ -99,3 +99,96 @@ class AvatarToolkit_OT_SelectShortestSeamPath(Operator): return {'FINISHED'} +class AvatarToolkit_OT_ExplodeMesh(Operator): + """Explodes the mesh for use with painting programs, or painting inside blender.""" + bl_idname = "avatar_toolkit.explode_mesh" + bl_label = t("Tools.explode_mesh") + bl_description = t("Tools.explode_mesh_desc") + bl_options = {'REGISTER', 'UNDO'} + distance: bpy.props.FloatProperty(default=2.0,name=t("Tools.explode_mesh.distance"),description=t("Tools.explode_mesh.distance_desc")) + split_on_seams: bpy.props.BoolProperty(default=True,name=t("Tools.explode_mesh.split_on_seams"),description=t("Tools.explode_mesh.split_on_seams_desc")) + + def draw(self, context: Context) -> None: + """Draw the operator's UI""" + layout = self.layout + layout.prop(self, "distance") + + def invoke(self, context: Context, event: bpy.types.Event) -> set[str]: + """Initialize the operator""" + return context.window_manager.invoke_props_dialog(self) + + @classmethod + def poll(cls, context: Context) -> bool: + + return context.view_layer.objects.active.type == "MESH" and len(context.view_layer.objects.selected) == 1 + + + + def execute(self, context: Context) -> Set[str]: + + mesh_obj: bpy.types.Object = context.view_layer.objects.active.type + mesh: bpy.types.Mesh = context.view_layer.objects.active.data + if(self.split_on_seams): + + #set to correct mode + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_mode(type='EDGE') + + #mark seams by islands + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.uv.select_all(action="SELECT") + bpy.ops.uv.seams_from_islands(mark_seams=True,mark_sharp=False) + + #clear selection + bpy.ops.mesh.select_all(action="DESELECT") + bpy.ops.object.mode_set(mode='OBJECT') + bm = bmesh.new() # create an empty BMesh + bm.from_mesh(mesh) # fill it in from active mesh + + #select seam edges + for idx,edge in enumerate(bm.edges): + edge.select = edge.seam + bm.to_mesh(mesh) + bm.free() + + #split edges. + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.edge_split() + + #separate by loose. + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_mode(type='FACE') + + bpy.ops.mesh.select_all(action="SELECT") + + bpy.ops.mesh.separate(type='LOOSE') + + + distance: float = self.distance + + + #set origins to geometry + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY",center="BOUNDS") + + #store original settings + origin_only_orig: bool = context.scene.tool_settings.use_transform_data_origin + pos_only_orig: bool = context.scene.tool_settings.use_transform_pivot_point_align + parents_only_orig: bool = context.scene.tool_settings.use_transform_skip_children + original_pivot: Literal['BOUNDING_BOX_CENTER', 'CURSOR', 'INDIVIDUAL_ORIGINS', 'MEDIAN_POINT', 'ACTIVE_ELEMENT'] = context.scene.tool_settings.transform_pivot_point + + #set scene settings correctly. + context.scene.tool_settings.use_transform_data_origin = False + context.scene.tool_settings.use_transform_pivot_point_align = True + context.scene.tool_settings.use_transform_skip_children = False + context.scene.tool_settings.transform_pivot_point = 'MEDIAN_POINT' + + #spread out separated objects + bpy.ops.transform.resize(value=(self.distance, self.distance, self.distance), orient_type='GLOBAL') + + #restore settings. + context.scene.tool_settings.use_transform_data_origin = origin_only_orig + context.scene.tool_settings.use_transform_pivot_point_align = pos_only_orig + context.scene.tool_settings.use_transform_skip_children = parents_only_orig + context.scene.tool_settings.transform_pivot_point = original_pivot + return {'FINISHED'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index cb7a600..e29ef01 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -215,6 +215,12 @@ "Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted", "Tools.find_shortest_seam_path": "Find Shortest Seam Path", "Tools.find_shortest_seam_path_desc": "Find shortest path of seams between two selected vertices connected to seams.", + "Tools.explode_mesh":"Explode Mesh for Painting", + "Tools.explode_mesh_desc": "Explodes the mesh for use with painting programs, or painting inside blender.", + "Tools.explode_mesh.distance": "Distance", + "Tools.explode_mesh.distance_desc": "Scale factor for distance between exploded items on model.", + "Tools.explode_mesh.split_on_seams_desc":"Split model on UV seams to separate islands from each other.", + "Tools.explode_mesh.split_on_seams":"Split on Seams", "Tools.apply_modifier_on_shapekey_obj":"Apply Modifier on Shapekey Object", "Tools.apply_modifier_on_shapekey_obj_desc":"Applies a modifier on an object regardless of it having shapekeys.", "Tools.merge_title": "Merge Tools", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index b8aa933..fd4f25c 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -18,7 +18,7 @@ from ..functions.tools.bone_tools import ( from ..functions.tools.standardize_armature import AvatarToolkit_OT_StandardizeArmature from ..functions.tools.merge_tools import AvatarToolkit_OT_MergeToActive, AvatarToolkit_OT_MergeToParent, AvatarToolkit_OT_ConnectBones from ..functions.tools.rigify_converter import AvatarToolkit_OT_ConvertRigifyToUnity -from ..functions.tools.general_mesh_tools import AvatarToolkit_OT_SelectShortestSeamPath +from ..functions.tools.general_mesh_tools import AvatarToolkit_OT_SelectShortestSeamPath, AvatarToolkit_OT_ExplodeMesh from ..functions.custom_tools.force_apply_modifier import AvatarToolkit_OT_ApplyModifierForShapkeyObj class AvatarToolKit_PT_ToolsPanel(Panel): @@ -68,6 +68,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.separator(factor=0.5) col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname,text=t("Tools.find_shortest_seam_path"),icon="MESH_DATA") col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname,text=t("Tools.apply_modifier_on_shapekey_obj"),icon="SHAPEKEY_DATA") + col.operator(AvatarToolkit_OT_ExplodeMesh.bl_idname,text=t("Tools.explode_mesh"),icon="MOD_EXPLODE") # Standardization Tools From 69cc03098f29e02d29edaacad722426d41c67496 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 10 Apr 2025 23:40:51 +0100 Subject: [PATCH 16/32] PMX Import now works --- core/importers/importer.py | 40 + core/importers/pmx/__ini__.py | 0 core/lamp.py | 66 + core/mmd/__init__.py | 26 + core/mmd/{core => }/bpyutils.py | 240 ++- core/mmd/core/__init__.py | 6 + core/mmd/{ => core}/bone.py | 305 ++-- core/mmd/core/camera.py | 257 +++ core/mmd/core/exceptions.py | 14 + core/mmd/core/lamp.py | 69 + core/mmd/{ => core}/material.py | 239 +-- core/mmd/core/model.py | 1208 +++++++++++++ core/mmd/core/morph.py | 798 +++++++++ core/mmd/core/pmx/__init__.py | 1625 ++++++++++++++++++ core/{importers => mmd/core}/pmx/importer.py | 366 ++-- core/mmd/core/rigid_body.py | 290 ++++ core/mmd/core/sdef.py | 334 ++++ core/mmd/core/shader.py | 346 ++++ core/mmd/core/translations.py | 738 ++++++++ core/mmd/core/vmd/__init__.py | 6 + core/mmd/core/vmd/importer.py | 673 ++++++++ core/mmd/cycles_converter.py | 243 +++ core/mmd/operators/__init__.py | 6 + core/mmd/operators/material.py | 406 +++++ core/mmd/operators/misc.py | 310 ++++ core/mmd/operators/model.py | 486 ++++++ core/mmd/operators/model_edit.py | 313 ++++ core/mmd/operators/morph.py | 776 +++++++++ core/mmd/operators/rigid_body.py | 579 +++++++ core/mmd/operators/sdef.py | 110 ++ core/mmd/operators/translations.py | 336 ++++ core/mmd/operators/view.py | 150 ++ core/mmd/properties/__init__.py | 34 + core/mmd/properties/material.py | 287 ++++ core/mmd/properties/morph.py | 488 ++++++ core/mmd/properties/pose_bone.py | 200 +-- core/mmd/properties/rigid_body.py | 295 ++++ core/mmd/properties/root.py | 23 +- core/mmd/properties/translations.py | 127 ++ core/mmd/translations.py | 461 +++++ core/mmd/{core => }/utils.py | 228 ++- cycles_converter.py | 240 +++ 42 files changed, 12920 insertions(+), 824 deletions(-) delete mode 100644 core/importers/pmx/__ini__.py create mode 100644 core/lamp.py rename core/mmd/{core => }/bpyutils.py (64%) create mode 100644 core/mmd/core/__init__.py rename core/mmd/{ => core}/bone.py (72%) create mode 100644 core/mmd/core/camera.py create mode 100644 core/mmd/core/exceptions.py create mode 100644 core/mmd/core/lamp.py rename core/mmd/{ => core}/material.py (75%) create mode 100644 core/mmd/core/model.py create mode 100644 core/mmd/core/morph.py create mode 100644 core/mmd/core/pmx/__init__.py rename core/{importers => mmd/core}/pmx/importer.py (77%) create mode 100644 core/mmd/core/rigid_body.py create mode 100644 core/mmd/core/sdef.py create mode 100644 core/mmd/core/shader.py create mode 100644 core/mmd/core/translations.py create mode 100644 core/mmd/core/vmd/__init__.py create mode 100644 core/mmd/core/vmd/importer.py create mode 100644 core/mmd/cycles_converter.py create mode 100644 core/mmd/operators/__init__.py create mode 100644 core/mmd/operators/material.py create mode 100644 core/mmd/operators/misc.py create mode 100644 core/mmd/operators/model.py create mode 100644 core/mmd/operators/model_edit.py create mode 100644 core/mmd/operators/morph.py create mode 100644 core/mmd/operators/rigid_body.py create mode 100644 core/mmd/operators/sdef.py create mode 100644 core/mmd/operators/translations.py create mode 100644 core/mmd/operators/view.py create mode 100644 core/mmd/properties/material.py create mode 100644 core/mmd/properties/morph.py create mode 100644 core/mmd/properties/rigid_body.py create mode 100644 core/mmd/properties/translations.py create mode 100644 core/mmd/translations.py rename core/mmd/{core => }/utils.py (52%) create mode 100644 cycles_converter.py diff --git a/core/importers/importer.py b/core/importers/importer.py index feb8e93..237fc92 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -8,6 +8,7 @@ from bpy_extras.io_utils import ImportHelper from typing import Optional, Callable, Dict, List, Union, Set from ..common import clear_default_objects from ..translations import t +from ..mmd.core.pmx.importer import PMXImporter # Configure logging logging.basicConfig(level=logging.INFO) @@ -94,6 +95,12 @@ import_types: Dict[str, ImportMethod] = { files=files, directory=directory, filepath=filepath, automatic_bone_orientation=False, use_prepost_rot=False, use_anim=False ), + "pmx": lambda directory, files, filepath: import_multi_files( + directory=directory, + files=files, + filepath=filepath, + method=lambda directory, filepath: import_pmx_file(filepath) + ), "smd": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), "dmx": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), "gltf": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath), @@ -193,3 +200,36 @@ class AvatarToolKit_OT_Import(Operator, ImportHelper): self.report({'INFO'}, t('Quick_Access.import_success')) return {'FINISHED'} +def import_pmx_file(filepath: str) -> None: + """ + Import a PMX file using the MMD Tools PMXImporter + + Args: + filepath: Path to the PMX file + """ + + # Default import settings + import_settings = { + "filepath": filepath, + "scale": 0.08, + "types": {"MESH", "ARMATURE", "MORPHS", "DISPLAY"}, + "clean_model": True, + "remove_doubles": False, + "fix_IK_links": True, + "ik_loop_factor": 3, + "use_mipmap": True, + "sph_blend_factor": 1.0, + "spa_blend_factor": 1.0, + "rename_LR_bones": False, + "use_underscore": False, + "apply_bone_fixed_axis": False, + } + + # Create and execute the importer + importer = PMXImporter() + try: + importer.execute(**import_settings) + logger.info(f"Successfully imported PMX file: {filepath}") + except Exception as e: + logger.error(f"Failed to import PMX file: {str(e)}", exc_info=True) + raise diff --git a/core/importers/pmx/__ini__.py b/core/importers/pmx/__ini__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/lamp.py b/core/lamp.py new file mode 100644 index 0000000..10593d3 --- /dev/null +++ b/core/lamp.py @@ -0,0 +1,66 @@ +# -*- 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/__init__.py b/core/mmd/__init__.py index e69de29..af8a62d 100644 --- a/core/mmd/__init__.py +++ b/core/mmd/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import os +import tomllib + +# This is a temporary workaround i be changing how MMD Tools works later when it comes to getting version number. + +try: + + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.dirname(os.path.dirname(current_dir)) + manifest_path = os.path.join(root_dir, 'blender_manifest.toml') + + if os.path.exists(manifest_path): + with open(manifest_path, 'rb') as f: + manifest = tomllib.load(f) + AVATAR_TOOLKIT_VERSION = manifest.get('version', '0.2.1') + else: + AVATAR_TOOLKIT_VERSION = '0.2.1' +except Exception: + AVATAR_TOOLKIT_VERSION = '0.2.1' \ No newline at end of file diff --git a/core/mmd/core/bpyutils.py b/core/mmd/bpyutils.py similarity index 64% rename from core/mmd/core/bpyutils.py rename to core/mmd/bpyutils.py index 2800d3c..c5c9d76 100644 --- a/core/mmd/core/bpyutils.py +++ b/core/mmd/bpyutils.py @@ -1,27 +1,28 @@ # -*- coding: utf-8 -*- -# Copyright 2013 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import contextlib -from typing import Generator, List, Optional, TypeVar, Dict, Any, Set, Tuple, Type +from typing import Generator, List, Optional, TypeVar import bpy -from bpy.types import Object, Material, Context -from mathutils import Vector, Matrix -from ...logging_setup import logger -from ...addon_preferences import get_preference, save_preference + +class Props: # For API changes of only name changed properties + show_in_front = "show_in_front" + display_type = "display_type" + display_size = "display_size" + empty_display_type = "empty_display_type" + empty_display_size = "empty_display_size" class __EditMode: - """Context manager for edit mode operations""" - def __init__(self, obj: Object): + def __init__(self, obj): if not isinstance(obj, bpy.types.Object): - raise ValueError("Expected a Blender Object") + raise ValueError self.__prevMode = obj.mode self.__obj = obj self.__obj_select = obj.select_get() @@ -40,18 +41,17 @@ class __EditMode: class __SelectObjects: - """Context manager for object selection operations""" - def __init__(self, active_object: Object, selected_objects: Optional[List[Object]] = None): + def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None): if not isinstance(active_object, bpy.types.Object): - raise ValueError("Expected a Blender Object") + raise ValueError try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: pass - context = FnContext.ensure_context() + contenxt = FnContext.ensure_context() - for i in context.selected_objects: + for i in contenxt.selected_objects: i.select_set(False) self.__active_object = active_object @@ -60,10 +60,10 @@ class __SelectObjects: self.__hides: List[bool] = [] for i in self.__selected_objects: self.__hides.append(i.hide_get()) - FnContext.select_object(context, i) - FnContext.set_active_object(context, active_object) + FnContext.select_object(contenxt, i) + FnContext.set_active_object(contenxt, active_object) - def __enter__(self) -> Object: + def __enter__(self) -> bpy.types.Object: return self.__active_object def __exit__(self, type, value, traceback): @@ -71,14 +71,12 @@ class __SelectObjects: i.hide_set(j) -def setParent(obj: Object, parent: Object) -> None: - """Set parent relationship between objects""" +def setParent(obj, parent): with select_object(parent, objects=[parent, obj]): bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False) -def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: - """Set parent relationship to a specific bone""" +def setParentToBone(obj, parent, bone_name): with select_object(parent, objects=[parent, obj]): bpy.ops.object.mode_set(mode="POSE") parent.data.bones.active = parent.data.bones[bone_name] @@ -86,7 +84,7 @@ def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: bpy.ops.object.mode_set(mode="OBJECT") -def edit_object(obj: Object): +def edit_object(obj): """Set the object interaction mode to 'EDIT' It is recommended to use 'edit_object' with 'with' statement like the following code. @@ -97,7 +95,7 @@ def edit_object(obj: Object): return __EditMode(obj) -def select_object(obj: Object, objects: Optional[List[Object]] = None): +def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None): """Select objects. It is recommended to use 'select_object' with 'with' statement like the following code. @@ -106,22 +104,20 @@ def select_object(obj: Object, objects: Optional[List[Object]] = None): with select_object(obj): some functions... """ + # TODO: Reimplement with bpy.context.temp_override (If it ain't broke, don't fix it.) return __SelectObjects(obj, objects) -def duplicateObject(obj: Object, total_len: int) -> List[Object]: - """Duplicate an object multiple times""" +def duplicateObject(obj, total_len): return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len) -def createObject(name: str = "Object", object_data: Optional[Any] = None, target_scene: Optional[Any] = None) -> Object: - """Create a new object and link it to the scene""" +def createObject(name="Object", object_data=None, target_scene=None): context = FnContext.ensure_context(target_scene) return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data)) -def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object: - """Create a sphere mesh object""" +def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None): import bmesh if target_object is None: @@ -142,8 +138,7 @@ def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, targe return target_object -def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optional[Object] = None) -> Object: - """Create a box mesh object""" +def makeBox(size=(1, 1, 1), target_object=None): import bmesh from mathutils import Matrix @@ -164,9 +159,9 @@ def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optiona return target_object -def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, height: float = 1.0, target_object: Optional[Object] = None) -> Object: - """Create a capsule mesh object""" +def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None): import math + import bmesh if target_object is None: @@ -179,6 +174,7 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig top = (0, 0, height / 2 + radius) verts.new(top) + # f = lambda i: radius*i/ring_count f = lambda i: radius * math.sin(0.5 * math.pi * i / ring_count) for i in range(ring_count, 0, -1): z = f(i - 1) @@ -228,12 +224,10 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig class TransformConstraintOp: - """Helper class for transform constraints""" __MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"} @staticmethod - def create(constraints, name: str, map_type: str): - """Create a transform constraint""" + def create(constraints, name, map_type): c = constraints.get(name, None) if c and c.type != "TRANSFORM": constraints.remove(c) @@ -251,8 +245,7 @@ class TransformConstraintOp: return c @classmethod - def min_max_attributes(cls, map_type: str, name_id: str = "") -> Tuple[str, ...]: - """Get min/max attribute names for a constraint type""" + def min_max_attributes(cls, map_type, name_id=""): key = (map_type, name_id) ret = cls.__MIN_MAX_MAP.get(key, None) if ret is None: @@ -262,8 +255,7 @@ class TransformConstraintOp: return ret @classmethod - def update_min_max(cls, constraint, value: float, influence: Optional[float] = 1): - """Update min/max values for a constraint""" + def update_min_max(cls, constraint, value, influence=1): c = constraint if not c or c.type != "TRANSFORM": return @@ -283,19 +275,18 @@ class TransformConstraintOp: class FnObject: - """Function collection for object operations""" def __init__(self): raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def mesh_remove_shape_key(mesh_object: Object, shape_key: bpy.types.ShapeKey) -> None: - """Remove a shape key from a mesh object, cleaning up drivers""" + def mesh_remove_shape_key(mesh_object: bpy.types.Object, shape_key: bpy.types.ShapeKey): assert isinstance(mesh_object.data, bpy.types.Mesh) key: bpy.types.Key = shape_key.id_data assert key == mesh_object.data.shape_keys if mesh_object.animation_data is not None: + fc_curve: bpy.types.FCurve for fc_curve in mesh_object.animation_data.drivers: if not fc_curve.data_path.startswith(shape_key.path_from_id()): continue @@ -311,52 +302,42 @@ class FnObject: mesh_object.active_shape_key_index = min(last_index, len(key_blocks) - 1) -T = TypeVar("T") +ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = TypeVar("ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE") class FnContext: - """Function collection for context operations""" def __init__(self): raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def ensure_context(context: Optional[Context] = None) -> Context: - """Get a valid context, using bpy.context if none provided""" + def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context: return context or bpy.context @staticmethod - def get_active_object(context: Context) -> Optional[Object]: - """Get the active object from context safely""" - if context is None or not hasattr(context, 'active_object'): - return None + def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]: return context.active_object @staticmethod - def set_active_object(context: Context, obj: Object) -> Object: - """Set the active object in context""" + def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: context.view_layer.objects.active = obj return obj @staticmethod - def set_active_and_select_single_object(context: Context, obj: Object) -> Object: - """Set an object as active and the only selected object""" + def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: return FnContext.set_active_object(context, FnContext.select_single_object(context, obj)) @staticmethod - def get_scene_objects(context: Context) -> List[Object]: - """Get all objects in the scene safely""" - if context is None or not hasattr(context, 'scene') or not hasattr(context.scene, 'objects'): - return [] + def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects: return context.scene.objects @staticmethod - def ensure_selectable(context: Context, obj: Object) -> Object: - """Make sure an object is selectable by unhiding it and its collections""" + def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: obj.hide_viewport = False obj.hide_select = False obj.hide_set(False) if obj not in context.selectable_objects: + def __layer_check(layer_collection: bpy.types.LayerCollection) -> bool: for lc in layer_collection.children: if __layer_check(lc): @@ -379,47 +360,47 @@ class FnContext: return obj @staticmethod - def select_object(context: Context, obj: Object) -> Object: - """Select an object in the context""" + def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: FnContext.ensure_selectable(context, obj).select_set(True) return obj @staticmethod - def select_objects(context: Context, *objects: Object) -> List[Object]: - """Select multiple objects in the context""" + def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]: return [FnContext.select_object(context, obj) for obj in objects] @staticmethod - def select_single_object(context: Context, obj: Object) -> Object: - """Select only the specified object, deselecting all others""" + def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: for i in context.selected_objects: if i != obj: i.select_set(False) return FnContext.select_object(context, obj) @staticmethod - def link_object(context: Context, obj: Object) -> Object: - """Link an object to the active collection""" + def link_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: context.collection.objects.link(obj) return obj @staticmethod - def new_and_link_object(context: Context, name: str, object_data: Optional[Any]) -> Object: - """Create a new object and link it to the active collection""" + def new_and_link_object(context: bpy.types.Context, name: str, object_data: Optional[bpy.types.ID]) -> bpy.types.Object: return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data)) @staticmethod - def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]: + def duplicate_object(context: bpy.types.Context, object_to_duplicate: bpy.types.Object, target_count: int) -> List[bpy.types.Object]: """ - Duplicate an object to reach the target count. - + Duplicate object. + + This function duplicates the given object and returns a list of duplicated objects. + Args: - context: The context in which the duplication is performed - object_to_duplicate: The object to be duplicated - target_count: The desired count of duplicated objects - + context (bpy.types.Context): The context in which the duplication is performed. + object_to_duplicate (bpy.types.Object): The object to be duplicated. + target_count (int): The desired count of duplicated objects. + Returns: - A list of duplicated objects + List[bpy.types.Object]: A list of duplicated objects. + + Raises: + AssertionError: If the number of selected objects in the context is not equal to 1 or if the selected object is not the same as the object to be duplicated. """ for o in context.selected_objects: o.select_set(False) @@ -443,16 +424,16 @@ class FnContext: return result_objects @staticmethod - def find_user_layer_collection_by_object(context: Context, target_object: Object) -> Optional[bpy.types.LayerCollection]: + def find_user_layer_collection_by_object(context: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]: """ - Find the layer collection containing the target object. - + Finds the layer collection that contains the given target_object in the user's collections. + Args: - context: The Blender context - target_object: The target object to find the layer collection for - + context (bpy.types.Context): The Blender context. + target_object (bpy.types.Object): The target object to find the layer collection for. + Returns: - The layer collection containing the target object, or None if not found + Optional[bpy.types.LayerCollection]: The layer collection that contains the target_object, or None if not found. """ scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection @@ -460,6 +441,7 @@ class FnContext: if layer_collection.name == name: return layer_collection + child_layer_collection: bpy.types.LayerCollection for child_layer_collection in layer_collection.children: found = find_layer_collection_by_name(child_layer_collection, name) if found is not None: @@ -467,6 +449,7 @@ class FnContext: return None + user_collection: bpy.types.Collection for user_collection in target_object.users_collection: found = find_layer_collection_by_name(scene_layer_collection, user_collection.name) if found is not None: @@ -476,16 +459,27 @@ class FnContext: @staticmethod @contextlib.contextmanager - def temp_override_active_layer_collection(context: Context, target_object: Object) -> Generator[Context, None, None]: + def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]: """ - Temporarily override the active layer collection to the one containing the target object. - + Context manager to temporarily override the active_layer_collection that contains the target object. + + This context manager allows you to temporarily change the active_layer_collection in the given context to the one that contains the target object. + It ensures that the original active_layer_collection is restored after the context is exited. + Args: - context: The context to modify - target_object: The object whose collection should become active - + context (bpy.types.Context): The context in which the active_layer_collection will be overridden. + target_object (bpy.types.Object): The target object whose layer collection will be set as the active_layer_collection. + Yields: - The modified context + bpy.types.Context: The modified context with the active_layer_collection overridden. + + Example: + with FnContext.temp_override_active_layer_collection(context, target_object): + # Perform operations with the modified context + bpy.ops.object.select_all(action='DESELECT') + target_object.select_set(True) + bpy.ops.object.delete() + """ original_layer_collection = context.view_layer.active_layer_collection target_layer_collection = FnContext.find_user_layer_collection_by_object(context, target_object) @@ -498,36 +492,30 @@ class FnContext: context.view_layer.active_layer_collection = original_layer_collection @staticmethod - @contextlib.contextmanager + def __get_addon_preferences(context: bpy.types.Context) -> Optional[bpy.types.AddonPreferences]: + addon: bpy.types.Addon = context.preferences.addons.get(__package__, None) + return addon.preferences if addon else None + + @staticmethod + def get_addon_preferences_attribute(context: bpy.types.Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE: + return getattr(FnContext.__get_addon_preferences(context), attribute_name, default_value) + + @staticmethod def temp_override_objects( - context: Context, - active_object: Optional[Object] = None, - selected_objects: Optional[List[Object]] = None, - **keywords - ) -> Generator[Context, None, None]: - """Create a temporary context override for object operations using Blender 4.4+ temp_override.""" - override_dict = {} - + context: bpy.types.Context, + window: Optional[bpy.types.Window] = None, + area: Optional[bpy.types.Area] = None, + region: Optional[bpy.types.Region] = None, + active_object: Optional[bpy.types.Object] = None, + selected_objects: Optional[List[bpy.types.Object]] = None, + **keywords, + ) -> Generator[bpy.types.Context, None, None]: if active_object is not None: - override_dict["active_object"] = active_object - override_dict["object"] = active_object + keywords["active_object"] = active_object + keywords["object"] = active_object if selected_objects is not None: - override_dict["selected_objects"] = selected_objects - override_dict["selected_editable_objects"] = selected_objects - - override_dict.update(keywords) - - with context.temp_override(**override_dict) as override_context: - yield override_context + keywords["selected_objects"] = selected_objects + keywords["selected_editable_objects"] = selected_objects - @staticmethod - def get_preference(key: str, default: T = None) -> T: - """ - Get a preference value using Avatar Toolkit's preference system.""" - return get_preference(key, default) - - @staticmethod - def save_preference(key: str, value: Any) -> None: - """Save a preference value using Avatar Toolkit's preference system.""" - save_preference(key, value) \ No newline at end of file + return context.temp_override(window=window, area=area, region=region, **keywords) diff --git a/core/mmd/core/__init__.py b/core/mmd/core/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/core/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/bone.py b/core/mmd/core/bone.py similarity index 72% rename from core/mmd/bone.py rename to core/mmd/core/bone.py index 7ac61f2..73fa58c 100644 --- a/core/mmd/bone.py +++ b/core/mmd/core/bone.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- -# Copyright 2013 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import math from typing import TYPE_CHECKING, Iterable, Optional, Set @@ -12,19 +11,13 @@ from typing import TYPE_CHECKING, Iterable, Optional, Set import bpy from mathutils import Vector -from ..logging_setup import logger -from .. import common -from ..common import ProgressTracker +from .. import bpyutils from ..bpyutils import TransformConstraintOp +from ..utils import ItemOp -# Constants for bone collections -BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools" -BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection" -BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection" -BONE_COLLECTION_NAME_SHADOW = "mmd_shadow" -BONE_COLLECTION_NAME_DUMMY = "mmd_dummy" - -SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NAME_DUMMY] +if TYPE_CHECKING: + from ..properties.root import MMDRoot, MMDDisplayItemFrame + from ..properties.pose_bone import MMDBone def remove_constraint(constraints, name): @@ -42,6 +35,15 @@ def remove_edit_bones(edit_bones, bone_names): edit_bones.remove(b) +BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection" +BONE_COLLECTION_NAME_SHADOW = "mmd_shadow" +BONE_COLLECTION_NAME_DUMMY = "mmd_dummy" + +SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NAME_DUMMY] + + class FnBone: AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指") @@ -77,6 +79,23 @@ class FnBone: bones = armature_object.pose.bones return (bones[b.name] for b in context_selected_bones if not bones[b.name].is_mmd_shadow_bone) + @staticmethod + def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True): + for b in FnBone.__get_selected_pose_bones(armature_object): + mmd_bone: MMDBone = b.mmd_bone + mmd_bone.enabled_fixed_axis = enable + lock_rotation = b.lock_rotation[:] + if enable: + axes = b.bone.matrix_local.to_3x3().transposed() + if lock_rotation.count(False) == 1: + mmd_bone.fixed_axis = axes[lock_rotation.index(False)].xzy + else: + mmd_bone.fixed_axis = axes[1].xzy # Y-axis + elif all(b.lock_location) and lock_rotation.count(True) > 1 and lock_rotation == (b.lock_ik_x, b.lock_ik_y, b.lock_ik_z): + # unlock transform locks if fixed axis was applied + b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = (False, False, False) + 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 @@ -217,81 +236,59 @@ class FnBone: display_item_frames.remove(i) mmd_root.active_display_item_frame = 0 - @staticmethod - def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True): - for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone = b.mmd_bone - mmd_bone.enabled_fixed_axis = enable - lock_rotation = b.lock_rotation[:] - if enable: - axes = b.bone.matrix_local.to_3x3().transposed() - if lock_rotation.count(False) == 1: - mmd_bone.fixed_axis = axes[lock_rotation.index(False)].xzy - else: - mmd_bone.fixed_axis = axes[1].xzy # Y-axis - elif all(b.lock_location) and lock_rotation.count(True) > 1 and lock_rotation == (b.lock_ik_x, b.lock_ik_y, b.lock_ik_z): - # unlock transform locks if fixed axis was applied - b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = (False, False, False) - b.lock_location = b.lock_scale = (False, False, False) - @staticmethod def apply_bone_fixed_axis(armature_object: bpy.types.Object): - with ProgressTracker(bpy.context, 100, "Applying Bone Fixed Axis") as progress: - bone_map = {} - for b in armature_object.pose.bones: - if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis: - continue - mmd_bone = b.mmd_bone - 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) - - progress.step("Processing bones") + bone_map = {} + for b in armature_object.pose.bones: + if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis: + continue + mmd_bone: MMDBone = b.mmd_bone + parent_tip = b.parent and not b.parent.is_mmd_shadow_bone and b.parent.mmd_bone.is_tip + bone_map[b.name] = (mmd_bone.fixed_axis.normalized(), mmd_bone.is_tip, parent_tip) - force_align = True - with common.edit_object(armature_object) as data: - bone: bpy.types.EditBone - for bone in data.edit_bones: - if bone.name not in bone_map: - bone.select = False - continue - fixed_axis, is_tip, parent_tip = bone_map[bone.name] - if fixed_axis.length: - axes = [bone.x_axis, bone.y_axis, bone.z_axis] - direction = fixed_axis.normalized().xzy - idx, val = max([(i, direction.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) - idx_1, idx_2 = (idx + 1) % 3, (idx + 2) % 3 - axes[idx] = -direction if val < 0 else direction - axes[idx_2] = axes[idx].cross(axes[idx_1]) - axes[idx_1] = axes[idx_2].cross(axes[idx]) - if parent_tip and bone.use_connect: - bone.use_connect = False - bone.head = bone.parent.head - if force_align: - tail = bone.head + axes[1].normalized() * bone.length - if is_tip or (tail - bone.tail).length > 1e-4: - for c in bone.children: - if c.use_connect: - c.use_connect = False - if is_tip: - c.head = bone.head - bone.tail = tail - bone.align_roll(axes[2]) - bone_map[bone.name] = tuple(i != idx for i in range(3)) - else: - bone_map[bone.name] = (True, True, True) - bone.select = True - - progress.step("Applying locks") - - for bone_name, locks in bone_map.items(): - b = armature_object.pose.bones[bone_name] - b.lock_location = (True, True, True) - b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = locks + force_align = True + with bpyutils.edit_object(armature_object) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False + continue + fixed_axis, is_tip, parent_tip = bone_map[bone.name] + if fixed_axis.length: + axes = [bone.x_axis, bone.y_axis, bone.z_axis] + direction = fixed_axis.normalized().xzy + idx, val = max([(i, direction.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) + idx_1, idx_2 = (idx + 1) % 3, (idx + 2) % 3 + axes[idx] = -direction if val < 0 else direction + axes[idx_2] = axes[idx].cross(axes[idx_1]) + axes[idx_1] = axes[idx_2].cross(axes[idx]) + if parent_tip and bone.use_connect: + bone.use_connect = False + bone.head = bone.parent.head + if force_align: + tail = bone.head + axes[1].normalized() * bone.length + if is_tip or (tail - bone.tail).length > 1e-4: + for c in bone.children: + if c.use_connect: + c.use_connect = False + if is_tip: + c.head = bone.head + bone.tail = tail + bone.align_roll(axes[2]) + bone_map[bone.name] = tuple(i != idx for i in range(3)) + else: + bone_map[bone.name] = (True, True, True) + bone.select = True + + for bone_name, locks in bone_map.items(): + b = armature_object.pose.bones[bone_name] + b.lock_location = (True, True, True) + 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): for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone = b.mmd_bone + mmd_bone: MMDBone = b.mmd_bone mmd_bone.enabled_local_axes = enable if enable: axes = b.bone.matrix_local.to_3x3().transposed() @@ -300,25 +297,22 @@ class FnBone: @staticmethod def apply_bone_local_axes(armature_object: bpy.types.Object): - with ProgressTracker(bpy.context, 100, "Applying Bone Local Axes") as progress: - bone_map = {} - for b in armature_object.pose.bones: - if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: + bone_map = {} + for b in armature_object.pose.bones: + if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: + continue + mmd_bone: MMDBone = b.mmd_bone + bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z) + + with bpyutils.edit_object(armature_object) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False continue - mmd_bone = b.mmd_bone - bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z) - - progress.step("Processing bones") - - with common.edit_object(armature_object) as data: - bone: bpy.types.EditBone - for bone in data.edit_bones: - if bone.name not in bone_map: - bone.select = False - continue - local_axis_x, local_axis_z = bone_map[bone.name] - FnBone.update_bone_roll(bone, local_axis_x, local_axis_z) - bone.select = True + local_axis_x, local_axis_z = bone_map[bone.name] + FnBone.update_bone_roll(bone, local_axis_x, local_axis_z) + bone.select = True @staticmethod def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z): @@ -336,21 +330,17 @@ class FnBone: @staticmethod def apply_auto_bone_roll(armature): - with ProgressTracker(bpy.context, 100, "Applying Auto Bone Roll") as progress: - bone_names = [] - 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) - - progress.step("Processing bones") - - with common.edit_object(armature) as data: - bone: bpy.types.EditBone - for bone in data.edit_bones: - if bone.name not in bone_names: - continue - FnBone.update_auto_bone_roll(bone) - bone.select = True + bone_names = [] + 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 + for bone in data.edit_bones: + if bone.name not in bone_names: + continue + FnBone.update_auto_bone_roll(bone) + bone.select = True @staticmethod def update_auto_bone_roll(edit_bone): @@ -385,8 +375,6 @@ class FnBone: @staticmethod def clean_additional_transformation(armature_object: bpy.types.Object): - logger.info(f"Cleaning additional transformations for {armature_object.name}") - # clean constraints p_bone: bpy.types.PoseBone for p_bone in armature_object.pose.bones: @@ -396,7 +384,6 @@ class FnBone: remove_constraint(constraints, "mmd_additional_location") if remove_constraint(constraints, "mmd_additional_parent"): p_bone.bone.use_inherit_rotation = True - # clean shadow bones shadow_bone_types = { "DUMMY", @@ -410,48 +397,41 @@ class FnBone: shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)] if len(shadow_bone_names) > 0: - with common.edit_object(armature_object) as data: + 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): - with ProgressTracker(bpy.context, 100, "Applying Additional Transformations") as progress: - def __is_dirty_bone(b): - if b.is_mmd_shadow_bone: - return False - mmd_bone = b.mmd_bone - if mmd_bone.has_additional_rotation or mmd_bone.has_additional_location: - return True - return mmd_bone.is_additional_transform_dirty + def __is_dirty_bone(b): + if b.is_mmd_shadow_bone: + return False + mmd_bone = b.mmd_bone + if mmd_bone.has_additional_rotation or mmd_bone.has_additional_location: + return True + return mmd_bone.is_additional_transform_dirty - dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)] - - progress.step("Setting up constraints") - - # setup constraints - shadow_bone_pool = [] - for p_bone in dirty_bones: - sb = FnBone.__setup_constraints(p_bone) - if sb: - shadow_bone_pool.append(sb) + dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)] - progress.step("Setting up shadow bones") - - # setup shadow bones - with common.edit_object(armature_object) as data: - edit_bones = data.edit_bones - for sb in shadow_bone_pool: - sb.update_edit_bones(edit_bones) + # setup constraints + shadow_bone_pool = [] + for p_bone in dirty_bones: + sb = FnBone.__setup_constraints(p_bone) + if sb: + shadow_bone_pool.append(sb) - pose_bones = armature_object.pose.bones + # setup shadow bones + with bpyutils.edit_object(armature_object) as data: + edit_bones = data.edit_bones for sb in shadow_bone_pool: - sb.update_pose_bones(pose_bones) + sb.update_edit_bones(edit_bones) - progress.step("Finalizing") - - # finish - for p_bone in dirty_bones: - p_bone.mmd_bone.is_additional_transform_dirty = False + pose_bones = armature_object.pose.bones + for sb in shadow_bone_pool: + sb.update_pose_bones(pose_bones) + + # finish + for p_bone in dirty_bones: + p_bone.mmd_bone.is_additional_transform_dirty = False @staticmethod def __setup_constraints(p_bone): @@ -459,7 +439,7 @@ class FnBone: mmd_bone = p_bone.mmd_bone influence = mmd_bone.additional_transform_influence target_bone = mmd_bone.additional_transform_bone - mute_rotation = not mmd_bone.has_additional_rotation + mute_rotation = not mmd_bone.has_additional_rotation # or p_bone.is_in_ik_chain mute_location = not mmd_bone.has_additional_location constraints = p_bone.constraints @@ -501,15 +481,12 @@ class MigrationFnBone: @staticmethod def fix_mmd_ik_limit_override(armature_object: bpy.types.Object): - with ProgressTracker(bpy.context, 100, "Fixing MMD IK Limit Override") as progress: - pose_bone: bpy.types.PoseBone - for pose_bone in armature_object.pose.bones: - constraint: bpy.types.Constraint - for constraint in pose_bone.constraints: - if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name: - constraint.owner_space = "LOCAL" - - progress.step("Fixed IK limit overrides") + pose_bone: bpy.types.PoseBone + for pose_bone in armature_object.pose.bones: + constraint: bpy.types.Constraint + for constraint in pose_bone.constraints: + if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name: + constraint.owner_space = "LOCAL" class _AT_ShadowBoneRemove: diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py new file mode 100644 index 0000000..9c5b2bd --- /dev/null +++ b/core/mmd/core/camera.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import math +from typing import Optional + +import bpy + +from ..bpyutils import FnContext, Props + + +class FnCamera: + @staticmethod + def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]: + if obj is None: + return None + if FnCamera.is_mmd_camera_root(obj): + return obj + if obj.parent is not None and FnCamera.is_mmd_camera_root(obj.parent): + return obj.parent + return None + + @staticmethod + def is_mmd_camera(obj: bpy.types.Object) -> bool: + return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None + + @staticmethod + def is_mmd_camera_root(obj: bpy.types.Object) -> bool: + 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): + d = id_data.driver_add(data_path, index).driver + d.type = "SCRIPTED" + if "$empty_distance" in expression: + v = d.variables.new() + v.name = "empty_distance" + v.type = "TRANSFORMS" + v.targets[0].id = camera_object + v.targets[0].transform_type = "LOC_Y" + v.targets[0].transform_space = "LOCAL_SPACE" + expression = expression.replace("$empty_distance", v.name) + if "$is_perspective" in expression: + v = d.variables.new() + v.name = "is_perspective" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "OBJECT" + v.targets[0].id = camera_object.parent + v.targets[0].data_path = "mmd_camera.is_perspective" + expression = expression.replace("$is_perspective", v.name) + if "$angle" in expression: + v = d.variables.new() + v.name = "angle" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "OBJECT" + v.targets[0].id = camera_object.parent + v.targets[0].data_path = "mmd_camera.angle" + expression = expression.replace("$angle", v.name) + if "$sensor_height" in expression: + v = d.variables.new() + v.name = "sensor_height" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "CAMERA" + v.targets[0].id = camera_object.data + v.targets[0].data_path = "sensor_height" + expression = expression.replace("$sensor_height", v.name) + + 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") + + @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") + + +class MigrationFnCamera: + @staticmethod + def update_mmd_camera(): + for camera_object in bpy.data.objects: + if camera_object.type != "CAMERA": + continue + + root_object = FnCamera.find_root(camera_object) + if root_object is None: + # It's not a MMD Camera + continue + + FnCamera.remove_drivers(camera_object) + FnCamera.add_drivers(camera_object) + + +class MMDCamera: + def __init__(self, obj): + root_object = FnCamera.find_root(obj) + if root_object is None: + raise ValueError("%s is not MMDCamera" % str(obj)) + + self.__emptyObj = getattr(root_object, "original", obj) + + @staticmethod + def isMMDCamera(obj: bpy.types.Object) -> bool: + return FnCamera.find_root(obj) is not None + + @staticmethod + def addDrivers(cameraObj: bpy.types.Object): + FnCamera.add_drivers(cameraObj) + + @staticmethod + def removeDrivers(cameraObj: bpy.types.Object): + if cameraObj.type != "CAMERA": + return + FnCamera.remove_drivers(cameraObj) + + @staticmethod + def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0): + if FnCamera.is_mmd_camera(cameraObj): + return MMDCamera(cameraObj) + + empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) + + cameraObj.parent = empty + cameraObj.data.sensor_fit = "VERTICAL" + cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV + cameraObj.data.ortho_scale = 25 * scale + cameraObj.data.clip_end = 500 * scale + setattr(cameraObj.data, Props.display_size, 5 * scale) + cameraObj.location = (0, -45 * scale, 0) + cameraObj.rotation_mode = "XYZ" + cameraObj.rotation_euler = (math.radians(90), 0, 0) + cameraObj.lock_location = (True, False, True) + cameraObj.lock_rotation = (True, True, True) + cameraObj.lock_scale = (True, True, True) + cameraObj.data.dof.focus_object = empty + FnCamera.add_drivers(cameraObj) + + empty.location = (0, 0, 10 * scale) + empty.rotation_mode = "YXZ" + setattr(empty, Props.empty_display_size, 5 * scale) + empty.lock_scale = (True, True, True) + empty.mmd_type = "CAMERA" + empty.mmd_camera.angle = math.radians(30) + empty.mmd_camera.persp = True + return MMDCamera(empty) + + @staticmethod + def newMMDCameraAnimation(cameraObj, cameraTarget=None, scale=1.0, min_distance=0.1): + scene = bpy.context.scene + mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera")) + FnContext.link_object(FnContext.ensure_context(), mmd_cam) + MMDCamera.convertToMMDCamera(mmd_cam, scale=scale) + mmd_cam_root = mmd_cam.parent + + _camera_override_func = 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 + + _target_override_func = 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) + + from math import atan + + from mathutils import Matrix, Vector + + render = scene.render + factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x) + matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1])) + neg_z_vector = Vector((0, 0, -1)) + frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current + frame_count = frame_end - frame_start + frames = range(frame_start, frame_end) + + 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) + + for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves)): + scene.frame_set(f) + if _camera_override_func: + cameraObj = _camera_override_func() + if _target_override_func: + cameraTarget = _target_override_func(cameraObj) + cam_matrix_world = cameraObj.matrix_world + cam_target_loc = cameraTarget.matrix_world.translation + cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode) + cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector + if cameraObj.data.type == "ORTHO": + cam_dis = -(9 / 5) * cameraObj.data.ortho_scale + if cameraObj.data.sensor_fit != "VERTICAL": + if cameraObj.data.sensor_fit == "HORIZONTAL": + cam_dis *= factor + else: + cam_dis *= min(1, factor) + else: + target_vec = cam_target_loc - cam_matrix_world.translation + cam_dis = -max(target_vec.length * cam_vec.dot(target_vec.normalized()), min_distance) + cam_target_loc = cam_matrix_world.translation - cam_vec * cam_dis + + tan_val = cameraObj.data.sensor_height / cameraObj.data.lens / 2 + 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" + + FnCamera.add_drivers(mmd_cam) + mmd_cam_root.animation_data_create().action = parent_action + mmd_cam.animation_data_create().action = distance_action + scene.frame_set(frame_current) + return MMDCamera(mmd_cam_root) + + def object(self): + return self.__emptyObj + + def camera(self): + for i in self.__emptyObj.children: + if i.type == "CAMERA": + return i + raise KeyError diff --git a/core/mmd/core/exceptions.py b/core/mmd/core/exceptions.py new file mode 100644 index 0000000..c89366a --- /dev/null +++ b/core/mmd/core/exceptions.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + + +class MaterialNotFoundError(KeyError): + """Exception raised when a material is not found in the scene""" + + def __init__(self, *args: object) -> None: + """Constructor for MaterialNotFoundError""" + super().__init__(*args) diff --git a/core/mmd/core/lamp.py b/core/mmd/core/lamp.py new file mode 100644 index 0000000..549a83b --- /dev/null +++ b/core/mmd/core/lamp.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +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/material.py b/core/mmd/core/material.py similarity index 75% rename from core/mmd/material.py rename to core/mmd/core/material.py index 576e212..68fba09 100644 --- a/core/mmd/material.py +++ b/core/mmd/core/material.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- -# Copyright 2013 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import logging import os @@ -13,27 +12,40 @@ from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast import bpy from mathutils import Vector -from ..logging_setup import logger +from ..bpyutils import FnContext from .exceptions import MaterialNotFoundError from .shader import _NodeGroupUtils if TYPE_CHECKING: from ..properties.material import MMDMaterial -# Constants for sphere modes +# TODO: use enum instead of constants SPHERE_MODE_OFF = 0 SPHERE_MODE_MULT = 1 SPHERE_MODE_ADD = 2 SPHERE_MODE_SUBTEX = 3 +class _DummyTexture: + def __init__(self, image): + self.type = "IMAGE" + self.image = image + self.use_mipmap = True + + +class _DummyTextureSlot: + def __init__(self, image): + self.diffuse_color_factor = 1 + self.uv_layer = "" + self.texture = _DummyTexture(image) + + class FnMaterial: __NODES_ARE_READONLY: bool = False def __init__(self, material: bpy.types.Material): self.__material = material self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY - logger.debug(f"Initializing FnMaterial for {material.name}") @staticmethod def set_nodes_are_readonly(nodes_are_readonly: bool): @@ -115,7 +127,7 @@ class FnMaterial: @property def material_id(self): - mmd_mat = self.__material.mmd_material + mmd_mat: MMDMaterial = self.__material.mmd_material if mmd_mat.material_id < 0: max_id = -1 for mat in bpy.data.materials: @@ -129,9 +141,11 @@ class FnMaterial: def __same_image_file(self, image, filepath): if image and image.source == "FILE": - img_filepath = bpy.path.abspath(image.filepath) + # pylint: disable=assignment-from-no-return + img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user() if img_filepath == filepath: return True + # pylint: disable=bare-except try: return os.path.samefile(img_filepath, filepath) except: @@ -141,34 +155,28 @@ class FnMaterial: def _load_image(self, filepath): img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None) if img is None: + # pylint: disable=bare-except try: img = bpy.data.images.load(filepath) - logger.debug(f"Loaded image from {filepath}") except: - logger.warning(f"Cannot create a texture for {filepath}. No such file.") + logging.warning("Cannot create a texture for %s. No such file.", filepath) img = bpy.data.images.new(os.path.basename(filepath), 1, 1) img.source = "FILE" img.filepath = filepath - # For Blender 4.4+ - if img.depth == 32 and img.file_format != "BMP": - img.alpha_mode = "CHANNEL_PACKED" - else: + use_alpha = img.depth == 32 and img.file_format != "BMP" + if hasattr(img, "use_alpha"): + img.use_alpha = use_alpha + elif not use_alpha: img.alpha_mode = "NONE" return img def update_toon_texture(self): if self._nodes_are_readonly: return - mmd_mat = self.__material.mmd_material + mmd_mat: MMDMaterial = self.__material.mmd_material if mmd_mat.is_shared_toon_texture: - # Get shared toon folder from preferences - context = bpy.context - addon_prefs = context.preferences.addons.get("avatar_toolkit", None) - if addon_prefs: - shared_toon_folder = addon_prefs.preferences.shared_toon_folder - else: - shared_toon_folder = "" - toon_path = os.path.join(shared_toon_folder, f"toon{mmd_mat.shared_toon_texture + 1:02d}.bmp") + 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)) self.create_toon_texture(bpy.path.resolve_ncase(path=toon_path)) elif mmd_mat.toon_texture != "": self.create_toon_texture(mmd_mat.toon_texture) @@ -192,15 +200,13 @@ class FnMaterial: if self._nodes_are_readonly: return mat = self.__material - mmd_mat = 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)),) - - # For Blender 4.4+ if hasattr(mat, "line_color"): # freestyle line color mat.line_color = line_color - mat_edge = bpy.data.materials.get("mmd_edge." + mat.name, None) + mat_edge: bpy.types.Material = bpy.data.materials.get("mmd_edge." + mat.name, None) if mat_edge: mat_edge.mmd_material.edge_color = line_color @@ -216,11 +222,11 @@ class FnMaterial: pass def get_texture(self): - return self.__get_texture_node("mmd_base_tex") + return self.__get_texture_node("mmd_base_tex", use_dummy=True) def create_texture(self, filepath): texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1)) - return texture + return _DummyTextureSlot(texture.image) def remove_texture(self): if self._nodes_are_readonly: @@ -228,7 +234,7 @@ class FnMaterial: self.__remove_texture_node("mmd_base_tex") def get_sphere_texture(self): - return self.__get_texture_node("mmd_sphere_tex") + return self.__get_texture_node("mmd_sphere_tex", use_dummy=True) def use_sphere_texture(self, use_sphere, obj=None): if self._nodes_are_readonly: @@ -241,7 +247,7 @@ class FnMaterial: def create_sphere_texture(self, filepath, obj=None): texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2)) self.update_sphere_texture_type(obj) - return texture + return _DummyTextureSlot(texture.image) def update_sphere_texture_type(self, obj=None): if self._nodes_are_readonly: @@ -258,8 +264,10 @@ class FnMaterial: texture = self.__get_texture_node("mmd_sphere_tex") if texture and (not texture.inputs["Vector"].is_linked or texture.inputs["Vector"].links[0].from_node.name == "mmd_tex_uv"): - # For Blender 4.4+ - texture.image.colorspace_settings.name = "Linear Rec.709" if is_sph_add else "sRGB" + if hasattr(texture, "color_space"): + texture.color_space = "NONE" if is_sph_add else "COLOR" + elif hasattr(texture.image, "colorspace_settings"): + texture.image.colorspace_settings.name = "Linear Rec.709" if is_sph_add else "sRGB" mat = self.material nodes, links = mat.node_tree.nodes, mat.node_tree.links @@ -269,7 +277,7 @@ class FnMaterial: next(uv_layers, None) # skip base UV subtex_uv = getattr(next(uv_layers, None), "name", "") if subtex_uv != "UV1": - logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex') + logging.info(' * material(%s): object "%s" use UV "%s" for SubTex', mat.name, obj.name, subtex_uv) links.new(nodes["mmd_tex_uv"].outputs["SubTex UV"], texture.inputs["Vector"]) else: links.new(nodes["mmd_tex_uv"].outputs["Sphere UV"], texture.inputs["Vector"]) @@ -280,7 +288,7 @@ class FnMaterial: self.__remove_texture_node("mmd_sphere_tex") def get_toon_texture(self): - return self.__get_texture_node("mmd_toon_tex") + return self.__get_texture_node("mmd_toon_tex", use_dummy=True) def use_toon_texture(self, use_toon): if self._nodes_are_readonly: @@ -289,18 +297,18 @@ class FnMaterial: def create_toon_texture(self, filepath): texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5)) - return texture + return _DummyTextureSlot(texture.image) def remove_toon_texture(self): if self._nodes_are_readonly: return self.__remove_texture_node("mmd_toon_tex") - def __get_texture_node(self, node_name): + def __get_texture_node(self, node_name, use_dummy=False): mat = self.material texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) if isinstance(texture, bpy.types.ShaderNodeTexImage): - return texture + return _DummyTexture(texture.image) if use_dummy else texture return None def __remove_texture_node(self, node_name): @@ -318,6 +326,7 @@ class FnMaterial: self.__update_shader_nodes() nodes = self.material.node_tree.nodes texture = nodes.new("ShaderNodeTexImage") + # pylint: disable=assignment-from-no-return texture.label = bpy.path.display_name(node_name) texture.name = node_name texture.location = nodes["mmd_shader"].location + Vector((pos[0] * 210, pos[1] * 220)) @@ -330,7 +339,6 @@ class FnMaterial: return mat = self.material mmd_mat = mat.mmd_material - # For Blender 4.4+ mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,)) @@ -339,7 +347,6 @@ class FnMaterial: return mat = self.material mmd_mat = mat.mmd_material - # For Blender 4.4+ mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,)) @@ -348,14 +355,17 @@ class FnMaterial: return mat = self.material mmd_mat = mat.mmd_material - - # For Blender 4.4+ - mat.blend_method = "HASHED" - - # Update alpha in diffuse_color - if len(mat.diffuse_color) > 3: + if hasattr(mat, "blend_method"): + mat.blend_method = "HASHED" # 'BLEND' + # mat.show_transparent_back = False + elif hasattr(mat, "transparency_method"): + mat.use_transparency = True + mat.transparency_method = "Z_TRANSPARENCY" + mat.game_settings.alpha_blend = "ALPHA" + if hasattr(mat, "alpha"): + mat.alpha = mmd_mat.alpha + elif len(mat.diffuse_color) > 3: mat.diffuse_color[3] = mmd_mat.alpha - self.__update_shader_input("Alpha", mmd_mat.alpha) self.update_self_shadow_map() @@ -372,11 +382,11 @@ class FnMaterial: return mat = self.material mmd_mat = mat.mmd_material - - # For Blender 4.4+ mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37) - mat.metallic = pow(1 - mat.roughness, 2.7) - + if hasattr(mat, "metallic"): + mat.metallic = pow(1 - mat.roughness, 2.7) + if hasattr(mat, "specular_hardness"): + mat.specular_hardness = mmd_mat.shininess self.__update_shader_input("Reflect", mmd_mat.shininess) def update_is_double_sided(self): @@ -384,10 +394,10 @@ class FnMaterial: return mat = self.material mmd_mat = mat.mmd_material - - # For Blender 4.4+ - mat.use_backface_culling = not mmd_mat.is_double_sided - + if hasattr(mat, "game_settings"): + mat.game_settings.use_backface_culling = not mmd_mat.is_double_sided + 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) def update_self_shadow_map(self): @@ -396,9 +406,8 @@ class FnMaterial: mat = self.material mmd_mat = mat.mmd_material cast_shadows = mmd_mat.enabled_self_shadow_map if mmd_mat.alpha > 1e-3 else False - - # For Blender 4.4+ - mat.shadow_method = "HASHED" if cast_shadows else "NONE" + if hasattr(mat, "shadow_method"): + mat.shadow_method = "HASHED" if cast_shadows else "NONE" def update_self_shadow(self): if self._nodes_are_readonly: @@ -424,8 +433,16 @@ class FnMaterial: return child return None - # For Blender 4.4+ - preferred_output_node_target = "EEVEE" + if hasattr(context, "engine"): + active_render_engine = context.engine + else: + # use ALL anyway + active_render_engine = "ALL" + + preferred_output_node_target = { + "CYCLES": "CYCLES", + "BLENDER_EEVEE_NEXT": "EEVEE", + }.get(active_render_engine, "ALL") tex_node = None for target in [preferred_output_node_target, "ALL"]: @@ -453,21 +470,25 @@ class FnMaterial: # ambient should be half the diffuse mmd_material.ambient_color = [x * 0.5 for x in mmd_material.diffuse_color] - # For Blender 4.4+ shadow_method = getattr(m, "shadow_method", None) if mmd_material.diffuse_color is None: mmd_material.diffuse_color = m.diffuse_color[:3] - - # For Blender 4.4+ - if len(m.diffuse_color) > 3: + if hasattr(m, "alpha"): + mmd_material.alpha = m.alpha + elif len(m.diffuse_color) > 3: mmd_material.alpha = m.diffuse_color[3] mmd_material.specular_color = m.specular_color - - # For Blender 4.4+ - mmd_material.shininess = pow(1 / max(m.roughness, 0.099), 1 / 0.37) - mmd_material.is_double_sided = not m.use_backface_culling + if hasattr(m, "specular_hardness"): + mmd_material.shininess = m.specular_hardness + else: + mmd_material.shininess = pow(1 / max(m.roughness, 0.099), 1 / 0.37) + + if hasattr(m, "game_settings"): + mmd_material.is_double_sided = not m.game_settings.use_backface_culling + elif hasattr(m, "use_backface_culling"): + mmd_material.is_double_sided = not m.use_backface_culling if shadow_method: mmd_material.enabled_self_shadow_map = (shadow_method != "NONE") and mmd_material.alpha > 1e-3 @@ -504,13 +525,13 @@ class FnMaterial: node_shader = nodes.get("mmd_shader", None) if node_shader is None: - node_shader = nodes.new("ShaderNodeGroup") + 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 = 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,) @@ -522,7 +543,7 @@ class FnMaterial: node_uv = nodes.get("mmd_tex_uv", None) if node_uv is None: - node_uv = nodes.new("ShaderNodeGroup") + 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)) node_uv.node_tree = self.__get_shader_uv() @@ -530,7 +551,7 @@ class FnMaterial: if not (node_shader.outputs["Shader"].is_linked or node_shader.outputs["Color"].is_linked or node_shader.outputs["Alpha"].is_linked): node_output = next((n for n in nodes if isinstance(n, bpy.types.ShaderNodeOutputMaterial) and n.is_active_output), None) if node_output is None: - node_output = nodes.new("ShaderNodeOutputMaterial") + node_output: bpy.types.ShaderNodeOutputMaterial = nodes.new("ShaderNodeOutputMaterial") node_output.is_active_output = True node_output.location = node_shader.location + Vector((400, 0)) links.new(node_shader.outputs["Shader"], node_output.inputs["Surface"]) @@ -548,26 +569,26 @@ class FnMaterial: def __get_shader_uv(self): group_name = "MMDTexUV" - shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + 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 ng = _NodeGroupUtils(shader) ############################################################################ - _node_output = ng.new_node("NodeGroupOutput", (6, 0)) + _node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (6, 0)) - tex_coord = ng.new_node("ShaderNodeTexCoord", (0, 0)) + tex_coord: bpy.types.ShaderNodeTexCoord = ng.new_node("ShaderNodeTexCoord", (0, 0)) - tex_coord1 = ng.new_node("ShaderNodeUVMap", (4, -2)) + tex_coord1: bpy.types.ShaderNodeUVMap = ng.new_node("ShaderNodeUVMap", (4, -2)) tex_coord1.uv_map = "UV1" - vec_trans = ng.new_node("ShaderNodeVectorTransform", (1, -1)) + vec_trans: bpy.types.ShaderNodeVectorTransform = ng.new_node("ShaderNodeVectorTransform", (1, -1)) vec_trans.vector_type = "NORMAL" vec_trans.convert_from = "OBJECT" vec_trans.convert_to = "CAMERA" - node_vector = ng.new_node("ShaderNodeMapping", (2, -1)) + node_vector: bpy.types.ShaderNodeMapping = ng.new_node("ShaderNodeMapping", (2, -1)) node_vector.vector_type = "POINT" node_vector.inputs["Location"].default_value = (0.5, 0.5, 0.0) node_vector.inputs["Scale"].default_value = (0.5, 0.5, 1.0) @@ -585,43 +606,43 @@ class FnMaterial: def __get_shader(self): group_name = "MMDShaderDev" - shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + 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 ng = _NodeGroupUtils(shader) ############################################################################ - node_input = ng.new_node("NodeGroupInput", (-5, -1)) - _node_output = ng.new_node("NodeGroupOutput", (11, 1)) + node_input: bpy.types.NodeGroupInput = ng.new_node("NodeGroupInput", (-5, -1)) + _node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (11, 1)) - node_diffuse = ng.new_mix_node("ADD", (-3, 4), fac=0.6) + node_diffuse: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (-3, 4), fac=0.6) node_diffuse.use_clamp = True - node_tex = ng.new_mix_node("MULTIPLY", (-2, 3.5)) - node_toon = ng.new_mix_node("MULTIPLY", (-1, 3)) - node_sph = ng.new_mix_node("MULTIPLY", (0, 2.5)) - node_spa = ng.new_mix_node("ADD", (0, 1.5)) - node_sphere = ng.new_mix_node("MIX", (1, 1)) + node_tex: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-2, 3.5)) + node_toon: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-1, 3)) + node_sph: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (0, 2.5)) + node_spa: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (0, 1.5)) + node_sphere: bpy.types.ShaderNodeMath = ng.new_mix_node("MIX", (1, 1)) - node_geo = ng.new_node("ShaderNodeNewGeometry", (6, 3.5)) - node_invert = ng.new_math_node("LESS_THAN", (7, 3)) - node_cull = ng.new_math_node("MAXIMUM", (8, 2.5)) - node_alpha = ng.new_math_node("MINIMUM", (9, 2)) + node_geo: bpy.types.ShaderNodeNewGeometry = ng.new_node("ShaderNodeNewGeometry", (6, 3.5)) + node_invert: bpy.types.ShaderNodeMath = ng.new_math_node("LESS_THAN", (7, 3)) + node_cull: bpy.types.ShaderNodeMath = ng.new_math_node("MAXIMUM", (8, 2.5)) + node_alpha: bpy.types.ShaderNodeMath = ng.new_math_node("MINIMUM", (9, 2)) node_alpha.use_clamp = True - node_alpha_tex = ng.new_math_node("MULTIPLY", (-1, -2)) - node_alpha_toon = ng.new_math_node("MULTIPLY", (0, -2.5)) - node_alpha_sph = ng.new_math_node("MULTIPLY", (1, -3)) + node_alpha_tex: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (-1, -2)) + node_alpha_toon: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (0, -2.5)) + node_alpha_sph: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (1, -3)) - node_reflect = ng.new_math_node("DIVIDE", (7, -1.5), value1=1) + node_reflect: bpy.types.ShaderNodeMath = ng.new_math_node("DIVIDE", (7, -1.5), value1=1) node_reflect.use_clamp = True - shader_diffuse = ng.new_node("ShaderNodeBsdfDiffuse", (8, 0)) - shader_glossy = ng.new_node("ShaderNodeBsdfAnisotropic", (8, -1)) - shader_base_mix = ng.new_node("ShaderNodeMixShader", (9, 0)) + shader_diffuse: bpy.types.ShaderNodeBsdfDiffuse = ng.new_node("ShaderNodeBsdfDiffuse", (8, 0)) + shader_glossy: bpy.types.ShaderNodeBsdfAnisotropic = ng.new_node("ShaderNodeBsdfAnisotropic", (8, -1)) + shader_base_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (9, 0)) shader_base_mix.inputs["Fac"].default_value = 0.02 - shader_trans = ng.new_node("ShaderNodeBsdfTransparent", (9, 1)) - shader_alpha_mix = ng.new_node("ShaderNodeMixShader", (10, 1)) + shader_trans: bpy.types.ShaderNodeBsdfTransparent = ng.new_node("ShaderNodeBsdfTransparent", (9, 1)) + shader_alpha_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (10, 1)) links = ng.links links.new(node_reflect.outputs["Value"], shader_glossy.inputs["Roughness"]) @@ -679,7 +700,7 @@ class FnMaterial: class MigrationFnMaterial: @staticmethod def update_mmd_shader(): - mmd_shader_node_tree = bpy.data.node_groups.get("MMDShaderDev") + mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev") if mmd_shader_node_tree is None: return @@ -687,11 +708,11 @@ class MigrationFnMaterial: if "Color" in ng.node_output.inputs: return - shader_diffuse = [n for n in mmd_shader_node_tree.nodes if n.type == "BSDF_DIFFUSE"][0] - node_sphere = shader_diffuse.inputs["Color"].links[0].from_node - node_output = ng.node_output - shader_alpha_mix = node_output.inputs["Shader"].links[0].from_node - node_alpha = shader_alpha_mix.inputs["Fac"].links[0].from_node + 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 + shader_alpha_mix: bpy.types.ShaderNodeMixShader = node_output.inputs["Shader"].links[0].from_node + node_alpha: bpy.types.ShaderNodeMath = shader_alpha_mix.inputs["Fac"].links[0].from_node ng.new_output_socket("Color", node_sphere.outputs["Color"]) ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) diff --git a/core/mmd/core/model.py b/core/mmd/core/model.py new file mode 100644 index 0000000..103d52f --- /dev/null +++ b/core/mmd/core/model.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +import logging +import time +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Optional, Set, TypeGuard, Union, cast + +import bpy +import idprop +import rna_prop_ui +from mathutils import Vector + +from .. import AVATAR_TOOLKIT_VERSION, bpyutils +from ..bpyutils import FnContext, Props +from . import rigid_body +from .morph import FnMorph +from .rigid_body import MODE_DYNAMIC, MODE_DYNAMIC_BONE, MODE_STATIC + +if TYPE_CHECKING: + from ..properties.morph import MaterialMorphData + from ..properties.rigid_body import MMDRigidBody + + +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): + FnModel.__copy_property(destination_root_object.mmd_root, source_root_object.mmd_root, overwrite=overwrite, replace_name2values=replace_name2values or {}) + + @staticmethod + def find_root_object(obj: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + """Find the root object of the model. + Args: + obj (bpy.types.Object): The object to start searching from. + Returns: + 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": + obj = obj.parent + return obj + + @staticmethod + def find_armature_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + """Find the armature object of the model. + Args: + root_object (bpy.types.Object): The root object of the model. + Returns: + Optional[bpy.types.Object]: The armature object of the model. If the model does not have an armature, None is returned. + """ + for o in root_object.children: + if o.type == "ARMATURE": + return o + return None + + @staticmethod + def find_rigid_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "RIGID_GRP_OBJ": + return o + return None + + @staticmethod + def __new_group_object(context: bpy.types.Context, name: str, mmd_type: str, parent: bpy.types.Object) -> bpy.types.Object: + group_object = FnContext.new_and_link_object(context, name=name, object_data=None) + group_object.mmd_type = mmd_type + group_object.parent = parent + group_object.hide_set(True) + group_object.hide_select = True + group_object.lock_rotation = group_object.lock_location = group_object.lock_scale = [True, True, True] + return group_object + + @staticmethod + def ensure_rigid_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + rigid_group_object = FnModel.find_rigid_group_object(root_object) + if rigid_group_object is not None: + return rigid_group_object + return FnModel.__new_group_object(context, name="rigidbodies", mmd_type="RIGID_GRP_OBJ", parent=root_object) + + @staticmethod + def find_joint_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "JOINT_GRP_OBJ": + return o + return None + + @staticmethod + def ensure_joint_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + joint_group_object = FnModel.find_joint_group_object(root_object) + if joint_group_object is not None: + return joint_group_object + return FnModel.__new_group_object(context, name="joints", mmd_type="JOINT_GRP_OBJ", parent=root_object) + + @staticmethod + def find_temporary_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "TEMPORARY_GRP_OBJ": + return o + return None + + @staticmethod + def ensure_temporary_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + temporary_group_object = FnModel.find_temporary_group_object(root_object) + if temporary_group_object is not None: + return temporary_group_object + return FnModel.__new_group_object(context, name="temporary", mmd_type="TEMPORARY_GRP_OBJ", parent=root_object) + + @staticmethod + def find_bone_order_mesh_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return None + + for o in armature_object.children: + if o.type == "MESH" and "mmd_bone_order_override" in o.modifiers: + return o + return None + + @staticmethod + def find_mesh_object_by_name(root_object: bpy.types.Object, name: str) -> Optional[bpy.types.Object]: + if not name: + return None + + for o in FnModel.iterate_mesh_objects(root_object): + if o.name == name or (hasattr(o.data, 'name') and o.data.name == name): + return o + return None + + @staticmethod + def iterate_child_objects(obj: bpy.types.Object) -> Iterator[bpy.types.Object]: + for child in obj.children: + yield child + yield from FnModel.iterate_child_objects(child) + + @staticmethod + def iterate_filtered_child_objects(condition_function: Callable[[bpy.types.Object], bool], obj: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if obj is None: + return iter(()) + return FnModel.__iterate_filtered_child_objects_internal(condition_function, obj) + + @staticmethod + def __iterate_filtered_child_objects_internal(condition_function: Callable[[bpy.types.Object], bool], obj: bpy.types.Object) -> Iterator[bpy.types.Object]: + for child in obj.children: + if condition_function(child): + yield child + yield from FnModel.__iterate_filtered_child_objects_internal(condition_function, child) + + @staticmethod + def __iterate_child_mesh_objects(obj: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + return FnModel.iterate_filtered_child_objects(FnModel.is_mesh_object, obj) + + @staticmethod + def iterate_mesh_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + return FnModel.__iterate_child_mesh_objects(FnModel.find_armature_object(root_object)) + + @staticmethod + def iterate_rigid_body_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + if root_object.mmd_root.is_built: + return itertools.chain( + FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_armature_object(root_object)), + FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_rigid_group_object(root_object)), + ) + return FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_rigid_group_object(root_object)) + + @staticmethod + def iterate_joint_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + return FnModel.iterate_filtered_child_objects(FnModel.is_joint_object, FnModel.find_joint_group_object(root_object)) + + @staticmethod + def iterate_temporary_objects(root_object: bpy.types.Object, rigid_track_only: bool = False) -> Iterator[bpy.types.Object]: + rigid_body_objects = FnModel.iterate_filtered_child_objects(FnModel.is_temporary_object, FnModel.find_rigid_group_object(root_object)) + + if rigid_track_only: + return rigid_body_objects + + temporary_group_object = FnModel.find_temporary_group_object(root_object) + if temporary_group_object is None: + return rigid_body_objects + return itertools.chain(rigid_body_objects, FnModel.__iterate_filtered_child_objects_internal(FnModel.is_temporary_object, temporary_group_object)) + + @staticmethod + def iterate_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: + return (material for mesh_object in FnModel.iterate_mesh_objects(root_object) for material in cast(bpy.types.Mesh, mesh_object.data).materials if material is not None) + + @staticmethod + def iterate_unique_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: + materials: Dict[bpy.types.Material, None] = {} # use dict because set does not guarantee the order + materials.update((material, None) for material in FnModel.iterate_materials(root_object)) + return iter(materials.keys()) + + @staticmethod + def is_root_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "ROOT" + + @staticmethod + def is_rigid_body_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "RIGID_BODY" + + @staticmethod + def is_joint_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "JOINT" + + @staticmethod + def is_temporary_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type in {"TRACK_TARGET", "NON_COLLISION_CONSTRAINT", "SPRING_CONSTRAINT", "SPRING_GOAL"} + + @staticmethod + def is_mesh_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + 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]): + parent_armature_object = FnModel.find_armature_object(parent_root_object) + with bpy.context.temp_override( + active_object=parent_armature_object, + selected_editable_objects=[parent_armature_object], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + def _change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs, pose_bones): + """This function will also update the references of bone morphs and rotate+/move+.""" + bone_id = bone.mmd_bone.bone_id + + # Change Bone ID + bone.mmd_bone.bone_id = new_bone_id + + # Update Relative Bone Morph # Update the reference of bone morph # 更新骨骼表情 + for bone_morph in bone_morphs: + for data in bone_morph.data: + if data.bone_id != bone_id: + continue + data.bone_id = new_bone_id + + # Update Relative Additional Transform # Update the reference of rotate+/move+ # 更新付与親 + for pose_bone in pose_bones: + if pose_bone.is_mmd_shadow_bone: + continue + mmd_bone = pose_bone.mmd_bone + if mmd_bone.additional_transform_bone_id != bone_id: + continue + mmd_bone.additional_transform_bone_id = new_bone_id + + max_bone_id = max( + ( + b.mmd_bone.bone_id + for o in itertools.chain( + child_root_objects, + [parent_root_object], + ) + for b in FnModel.find_armature_object(o).pose.bones + if not b.is_mmd_shadow_bone + ), + default=-1, + ) + + child_root_object: bpy.types.Object + for child_root_object in child_root_objects: + 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 + + for pose_bone in child_pose_bones: + if pose_bone.is_mmd_shadow_bone: + continue + if pose_bone.mmd_bone.bone_id != -1: + max_bone_id += 1 + _change_bone_id(pose_bone, max_bone_id, child_bone_morphs, child_pose_bones) + + child_armature_matrix = child_armature_object.matrix_parent_inverse.copy() + + with bpy.context.temp_override( + active_object=child_armature_object, + selected_editable_objects=[child_armature_object], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + # Disconnect mesh dependencies because transform_apply fails when mesh data are multiple used. + related_meshes: Dict[MaterialMorphData, bpy.types.Mesh] = {} + for material_morph in child_root_object.mmd_root.material_morphs: + for material_morph_data in material_morph.data: + if material_morph_data.related_mesh_data is not None: + related_meshes[material_morph_data] = material_morph_data.related_mesh_data + material_morph_data.related_mesh_data = None + try: + # replace mesh armature modifier.object + mesh: bpy.types.Object + for mesh in FnModel.__iterate_child_mesh_objects(child_armature_object): + with bpy.context.temp_override( + active_object=mesh, + selected_editable_objects=[mesh], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + finally: + # Restore mesh dependencies + for material_morph in child_root_object.mmd_root.material_morphs: + for material_morph_data in material_morph.data: + material_morph_data.related_mesh_data = related_meshes.get(material_morph_data, None) + + # join armatures + with bpy.context.temp_override( + active_object=parent_armature_object, + selected_editable_objects=[parent_armature_object, child_armature_object], + ): + bpy.ops.object.join() + + for mesh in FnModel.__iterate_child_mesh_objects(parent_armature_object): + armature_modifier: bpy.types.ArmatureModifier = mesh.modifiers["mmd_bone_order_override"] if "mmd_bone_order_override" in mesh.modifiers else mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") + if armature_modifier.object is None: + armature_modifier.object = parent_armature_object + mesh.matrix_parent_inverse = child_armature_matrix + + child_rigid_group_object = FnModel.find_rigid_group_object(child_root_object) + if child_rigid_group_object is not None: + parent_rigid_group_object = FnModel.find_rigid_group_object(parent_root_object) + + with bpy.context.temp_override( + object=parent_rigid_group_object, + selected_editable_objects=[parent_rigid_group_object, *FnModel.iterate_rigid_body_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + bpy.data.objects.remove(child_rigid_group_object) + + child_joint_group_object = FnModel.find_joint_group_object(child_root_object) + if child_joint_group_object is not None: + parent_joint_group_object = FnModel.find_joint_group_object(parent_root_object) + with bpy.context.temp_override( + object=parent_joint_group_object, + selected_editable_objects=[parent_joint_group_object, *FnModel.iterate_joint_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + bpy.data.objects.remove(child_joint_group_object) + + child_temporary_group_object = FnModel.find_temporary_group_object(child_root_object) + if child_temporary_group_object is not None: + parent_temporary_group_object = FnModel.find_temporary_group_object(parent_root_object) + with bpy.context.temp_override( + object=parent_temporary_group_object, + selected_editable_objects=[parent_temporary_group_object, *FnModel.iterate_temporary_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + for obj in list(FnModel.iterate_child_objects(child_temporary_group_object)): + bpy.data.objects.remove(obj) + bpy.data.objects.remove(child_temporary_group_object) + + FnModel.copy_mmd_root(parent_root_object, child_root_object, overwrite=False) + + # Remove unused objects from child models + if len(child_root_object.children) == 0: + bpy.data.objects.remove(child_root_object) + + @staticmethod + def _add_armature_modifier(mesh_object: bpy.types.Object, armature_object: bpy.types.Object) -> bpy.types.ArmatureModifier: + for m in mesh_object.modifiers: + if m.type != "ARMATURE": + continue + # already has armature modifier. + return cast(bpy.types.ArmatureModifier, m) + + modifier = cast(bpy.types.ArmatureModifier, mesh_object.modifiers.new(name="Armature", type="ARMATURE")) + modifier.object = armature_object + modifier.use_vertex_groups = True + modifier.name = "mmd_bone_order_override" + + return modifier + + @staticmethod + def attach_mesh_objects(parent_root_object: bpy.types.Object, mesh_objects: Iterable[bpy.types.Object], add_armature_modifier: bool): + armature_object = FnModel.find_armature_object(parent_root_object) + if armature_object is None: + raise ValueError(f"Armature object not found in {parent_root_object}") + + def __get_root_object(obj: bpy.types.Object) -> bpy.types.Object: + if obj.parent is None: + return obj + return __get_root_object(obj.parent) + + for mesh_object in mesh_objects: + if not FnModel.is_mesh_object(mesh_object): + continue + + if FnModel.find_root_object(mesh_object) is not None: + continue + + mesh_root_object = __get_root_object(mesh_object) + original_matrix_world = mesh_root_object.matrix_world + mesh_root_object.parent_type = "OBJECT" + mesh_root_object.parent = armature_object + mesh_root_object.matrix_world = original_matrix_world + + if add_armature_modifier: + FnModel._add_armature_modifier(mesh_object, armature_object) + + @staticmethod + def add_missing_vertex_groups_from_bones(root_object: bpy.types.Object, mesh_object: bpy.types.Object, search_in_all_meshes: bool): + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + raise ValueError(f"Armature object not found in {root_object}") + + vertex_group_names: Set[str] = set() + + search_meshes = FnModel.iterate_mesh_objects(root_object) if search_in_all_meshes else [mesh_object] + + for search_mesh in search_meshes: + vertex_group_names.update(search_mesh.vertex_groups.keys()) + + pose_bone: bpy.types.PoseBone + for pose_bone in armature_object.pose.bones: + pose_bone_name = pose_bone.name + + if pose_bone_name in vertex_group_names: + continue + + if pose_bone_name.startswith("_"): + continue + + mesh_object.vertex_groups.new(name=pose_bone_name) + + @staticmethod + def change_mmd_ik_loop_factor(root_object: bpy.types.Object, new_ik_loop_factor: int): + mmd_root = root_object.mmd_root + old_ik_loop_factor = mmd_root.ik_loop_factor + + if new_ik_loop_factor == old_ik_loop_factor: + return + + armature_object = FnModel.find_armature_object(root_object) + 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) + constraint.iterations = iterations + + mmd_root.ik_loop_factor = new_ik_loop_factor + + return + + @staticmethod + def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): + destination_rna_properties = destination.bl_rna.properties + for name in source.keys(): + is_attr = hasattr(source, name) + value = getattr(source, name) if is_attr else source[name] + if isinstance(value, bpy.types.PropertyGroup): + FnModel.__copy_property_group(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + elif isinstance(value, bpy.types.bpy_prop_collection): + FnModel.__copy_collection_property(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + elif isinstance(value, idprop.types.IDPropertyArray): + pass + # _copy_collection_property(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + else: + value2values = replace_name2values.get(name) + if value2values is not None: + replace_value = value2values.get(value) + if replace_value is not None: + value = replace_value + + if overwrite or destination_rna_properties[name].default == getattr(destination, name) if is_attr else destination[name]: + if is_attr: + setattr(destination, name, value) + else: + 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]]): + if overwrite: + destination.clear() + + len_source = len(source) + if len_source == 0: + return + + source_names: Set[str] = set(source.keys()) + if len(source_names) == len_source and source[0].name != "": + # names work + destination_names: Set[str] = set(destination.keys()) + + missing_names = source_names - destination_names + + destination_index = 0 + for name, value in source.items(): + if name in missing_names: + new_element = destination.add() + new_element["name"] = name + + FnModel.__copy_property(destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + destination.move(destination.find(name), destination_index) + destination_index += 1 + else: + # names not work + while len_source > len(destination): + destination.add() + + for index, name in enumerate(source.keys()): + 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]]): + 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}") + + @staticmethod + def initalize_display_item_frames(root_object: bpy.types.Object, reset: bool = True): + frames = root_object.mmd_root.display_item_frames + if reset and len(frames) > 0: + root_object.mmd_root.active_display_item_frame = 0 + frames.clear() + + frame_names = {"Root": "Root", "表情": "Facial"} + + for frame_name, frame_name_e in frame_names.items(): + frame = frames.get(frame_name, None) or frames.add() + frame.name = frame_name + frame.name_e = frame_name_e + frame.is_special = True + + arm = FnModel.find_armature_object(root_object) + if arm is not None and len(arm.data.bones) > 0 and len(frames[0].data) < 1: + item = frames[0].data.add() + item.type = "BONE" + item.name = arm.data.bones[0].name + + if not reset: + frames.move(frames.find("Root"), 0) + frames.move(frames.find("表情"), 1) + + @staticmethod + def get_empty_display_size(root_object: bpy.types.Object) -> float: + return getattr(root_object, Props.empty_display_size) + + +class MigrationFnModel: + """Migration Functions for old MMD models broken by bugs or issues""" + + @classmethod + def update_mmd_ik_loop_factor(cls): + for armature_object in bpy.data.objects: + if armature_object.type != "ARMATURE": + continue + + if "mmd_ik_loop_factor" not in armature_object: + return + + FnModel.find_root_object(armature_object).mmd_root.ik_loop_factor = max(armature_object["mmd_ik_loop_factor"], 1) + del armature_object["mmd_ik_loop_factor"] + + @staticmethod + def update_avatar_toolkit_version(): + for root_object in bpy.data.objects: + if root_object.type != "EMPTY": + continue + + if not FnModel.is_root_object(root_object): + continue + + if "avatar_toolkit_version" in root_object: + continue + + root_object["avatar_toolkit_version"] = "0.2.1" + + +class Model: + def __init__(self, root_obj): + if root_obj is None: + raise ValueError("must be MMD ROOT type object") + if root_obj.mmd_type != "ROOT": + raise ValueError("must be MMD ROOT type object") + self.__root: bpy.types.Object = getattr(root_obj, "original", root_obj) + self.__arm: Optional[bpy.types.Object] = None + self.__rigid_grp: Optional[bpy.types.Object] = None + self.__joint_grp: Optional[bpy.types.Object] = None + self.__temporary_grp: Optional[bpy.types.Object] = None + + @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): + if obj_name is None: + obj_name = name + + context = FnContext.ensure_context() + + root: bpy.types.Object = bpy.data.objects.new(name=obj_name, object_data=None) + root.mmd_type = "ROOT" + root.mmd_root.name = name + root.mmd_root.name_e = name_e + root["avatar_toolkit_version"] = AVATAR_TOOLKIT_VERSION + setattr(root, Props.empty_display_size, scale / 0.2) + FnContext.link_object(context, root) + + if armature_object: + m = armature_object.matrix_world + armature_object.parent_type = "OBJECT" + armature_object.parent = root + # armature_object.matrix_world = m + root.matrix_world = m + armature_object.matrix_local.identity() + else: + 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) + armature_object.lock_rotation = armature_object.lock_location = armature_object.lock_scale = [True, True, True] + setattr(armature_object, Props.show_in_front, True) + setattr(armature_object, Props.display_type, "WIRE") + + from .bone import FnBone + + FnBone.setup_special_bone_collections(armature_object) + + if add_root_bone: + bone_name = "全ての親" + bone_name_english = "Root" + + # Create the root bone + with bpyutils.edit_object(armature_object) as data: + bone = data.edit_bones.new(name=bone_name) + bone.head = (0.0, 0.0, 0.0) + bone.tail = (0.0, 0.0, getattr(root, Props.empty_display_size)) + + # Set MMD bone properties + pose_bone = armature_object.pose.bones[bone_name] + pose_bone.mmd_bone.name_j = bone_name + pose_bone.mmd_bone.name_e = bone_name_english + + # Create a bone collection named "Root" + bone_collection_name = bone_name_english + bone_collection = armature_object.data.collections.new(name=bone_collection_name) + + # Assign the new bone to the bone collection + data_bone = armature_object.data.bones[bone_name] + bone_collection.assign(data_bone) + + FnContext.set_active_and_select_single_object(context, root) + 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): + FnModel.initalize_display_item_frames(self.__root, reset=reset) + + @property + def morph_slider(self): + return FnMorph.get_morph_slider(self) + + def loadMorphs(self): + FnMorph.load_morphs(self) + + def create_ik_constraint(self, bone, ik_target): + """create IK constraint + + Args: + bone: A pose bone to add a IK constraint + id_target: A pose bone for IK target + + Returns: + The bpy.types.KinematicConstraint object created. It is set target + and subtarget options. + + """ + ik_target_name = ik_target.name + ik_const = bone.constraints.new("IK") + ik_const.target = self.__arm + ik_const.subtarget = ik_target_name + return ik_const + + def allObjects(self, obj: Optional[bpy.types.Object] = None) -> Iterator[bpy.types.Object]: + if obj is None: + obj: bpy.types.Object = self.__root + yield obj + yield from FnModel.iterate_child_objects(obj) + + def rootObject(self) -> bpy.types.Object: + return self.__root + + def armature(self) -> bpy.types.Object: + if self.__arm is None: + self.__arm = FnModel.find_armature_object(self.__root) + assert self.__arm is not None + return self.__arm + + def hasRigidGroupObject(self) -> bool: + return FnModel.find_rigid_group_object(self.__root) is not None + + def rigidGroupObject(self) -> bpy.types.Object: + if self.__rigid_grp is None: + self.__rigid_grp = FnModel.find_rigid_group_object(self.__root) + if self.__rigid_grp is None: + rigids = bpy.data.objects.new(name="rigidbodies", object_data=None) + FnContext.link_object(FnContext.ensure_context(), rigids) + rigids.mmd_type = "RIGID_GRP_OBJ" + rigids.parent = self.__root + rigids.hide_set(True) + rigids.hide_select = True + rigids.lock_rotation = rigids.lock_location = rigids.lock_scale = [True, True, True] + self.__rigid_grp = rigids + return self.__rigid_grp + + def hasJointGroupObject(self) -> bool: + return FnModel.find_joint_group_object(self.__root) is not None + + def jointGroupObject(self) -> bpy.types.Object: + if self.__joint_grp is None: + self.__joint_grp = FnModel.find_joint_group_object(self.__root) + if self.__joint_grp is None: + joints = bpy.data.objects.new(name="joints", object_data=None) + FnContext.link_object(FnContext.ensure_context(), joints) + joints.mmd_type = "JOINT_GRP_OBJ" + joints.parent = self.__root + joints.hide_set(True) + joints.hide_select = True + joints.lock_rotation = joints.lock_location = joints.lock_scale = [True, True, True] + self.__joint_grp = joints + return self.__joint_grp + + def hasTemporaryGroupObject(self) -> bool: + return FnModel.find_temporary_group_object(self.__root) is not None + + def temporaryGroupObject(self) -> bpy.types.Object: + if self.__temporary_grp is None: + self.__temporary_grp = FnModel.find_temporary_group_object(self.__root) + if self.__temporary_grp is None: + temporarys = bpy.data.objects.new(name="temporary", object_data=None) + FnContext.link_object(FnContext.ensure_context(), temporarys) + temporarys.mmd_type = "TEMPORARY_GRP_OBJ" + temporarys.parent = self.__root + temporarys.hide_set(True) + temporarys.hide_select = True + temporarys.lock_rotation = temporarys.lock_location = temporarys.lock_scale = [True, True, True] + self.__temporary_grp = temporarys + return self.__temporary_grp + + 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): + FnModel.attach_mesh_objects(self.rootObject(), meshes, add_armature_modifier) + + def firstMesh(self) -> Optional[bpy.types.Object]: + for i in self.meshes(): + return i + return None + + def findMesh(self, mesh_name) -> Optional[bpy.types.Object]: + """ + Helper method to find a mesh by name + """ + if mesh_name == "": + return None + for mesh in self.meshes(): + if mesh.name == mesh_name or mesh.data.name == mesh_name: + return mesh + return None + + def findMeshByIndex(self, index: int) -> Optional[bpy.types.Object]: + """ + Helper method to find the mesh by index + """ + if index < 0: + return None + for i, mesh in enumerate(self.meshes()): + if i == index: + return mesh + return None + + def getMeshIndex(self, mesh_name: str) -> int: + """ + Helper method to get the index of a mesh. Returns -1 if not found + """ + if mesh_name == "": + return -1 + for i, mesh in enumerate(self.meshes()): + if mesh.name == mesh_name or mesh.data.name == mesh_name: + return i + return -1 + + def rigidBodies(self) -> Iterator[bpy.types.Object]: + return FnModel.iterate_rigid_body_objects(self.__root) + + 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]: + 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 + 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): + if old_bone_name == new_bone_name: + return + armature = self.armature() + bone = armature.pose.bones[old_bone_name] + bone.name = new_bone_name + new_bone_name = bone.name + + mmd_root = self.rootObject().mmd_root + for frame in mmd_root.display_item_frames: + for item in frame.data: + if item.type == "BONE" and item.name == old_bone_name: + item.name = new_bone_name + for mesh in self.meshes(): + 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): + rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) + if self.__root.mmd_root.is_built: + self.clean() + self.__root.mmd_root.is_built = True + logging.info("****************************************") + logging.info(" Build rig") + logging.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) + rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) + + def clean(self): + rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) + logging.info("****************************************") + logging.info(" Clean rig") + logging.info("****************************************") + start_time = time.time() + + pose_bones = [] + arm = self.armature() + if arm is not None: + pose_bones = arm.pose.bones + for i in pose_bones: + if "mmd_tools_rigid_track" in i.constraints: + const = i.constraints["mmd_tools_rigid_track"] + i.constraints.remove(const) + + 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) + i.mmd_rigid.bone = i.mmd_rigid.bone + relation = i.constraints["mmd_tools_rigid_parent"] + relation.mute = True + if rigid_type == rigid_body.MODE_STATIC: + i.parent_type = "OBJECT" + i.parent = self.rigidGroupObject() + elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name != "": + for c in arm.pose.bones[bone_name].constraints: + if c.type == "IK": + c.mute = False + self.__restoreTransforms(i) + + for i in self.joints(): + self.__restoreTransforms(i) + + self.__removeTemporaryObjects() + self.connectPhysicsBones() + + arm = self.armature() + if arm is not None: # update armature + arm.update_tag() + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + 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) + mmd_root.is_built = False + rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) + + def __removeTemporaryObjects(self): + with bpy.context.temp_override(selected_objects=tuple(self.temporaryObjects()), active_object=self.rootObject()): + bpy.ops.object.delete() + + def __restoreTransforms(self, obj): + 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] + + def __backupTransforms(self, obj): + 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) + + def __preBuild(self): + self.__fake_parent_map = {} + self.__rigid_body_matrix_map = {} + self.__empty_parent_map = {} + + no_parents = [] + for i in self.rigidBodies(): + self.__backupTransforms(i) + # mute relation + relation = i.constraints["mmd_tools_rigid_parent"] + relation.mute = True + # mute IK + if int(i.mmd_rigid.type) in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name != "": + for c in arm.pose.bones[bone_name].constraints: + if c.type == "IK": + c.mute = True + c.influence = c.influence # trigger update + else: + no_parents.append(i) + # update changes of armature constraints + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + parented = [] + for i in self.joints(): + self.__backupTransforms(i) + rbc = i.rigid_body_constraint + if rbc is None: + continue + obj1, obj2 = rbc.object1, rbc.object2 + if obj2 in no_parents: + if obj1 not in no_parents and obj2 not in parented: + self.__fake_parent_map.setdefault(obj1, []).append(obj2) + parented.append(obj2) + elif obj1 in no_parents: + if obj1 not in parented: + self.__fake_parent_map.setdefault(obj2, []).append(obj1) + parented.append(obj1) + + # assert(len(no_parents) == len(parented)) + + def __postBuild(self): + self.__fake_parent_map = None + self.__rigid_body_matrix_map = None + + # update changes + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + # parenting empty to rigid object at once for speeding up + for empty, rigid_obj in self.__empty_parent_map.items(): + matrix_world = empty.matrix_world + empty.parent = rigid_obj + empty.matrix_world = matrix_world + self.__empty_parent_map = None + + arm = self.armature() + if arm: + for p_bone in arm.pose.bones: + c = p_bone.constraints.get("mmd_tools_rigid_track", None) + if c: + c.mute = False + + def updateRigid(self, rigid_obj: bpy.types.Object, collision_margin: float): + assert rigid_obj.mmd_type == "RIGID_BODY" + rb = rigid_obj.rigid_body + if rb is None: + return + + rigid = rigid_obj.mmd_rigid + rigid_type = int(rigid.type) + relation = rigid_obj.constraints["mmd_tools_rigid_parent"] + + if relation.target is None: + relation.target = self.armature() + + arm = relation.target + if relation.subtarget not in arm.pose.bones: + bone_name = "" + else: + bone_name = relation.subtarget + + if rigid_type == rigid_body.MODE_STATIC: + rb.kinematic = True + else: + rb.kinematic = False + + if collision_margin == 0.0: + rb.use_margin = False + else: + rb.use_margin = True + rb.collision_margin = collision_margin + + if arm is not None and bone_name != "": + target_bone = arm.pose.bones[bone_name] + + if rigid_type == rigid_body.MODE_STATIC: + m = target_bone.matrix @ target_bone.bone.matrix_local.inverted() + self.__rigid_body_matrix_map[rigid_obj] = m + orig_scale = rigid_obj.scale.copy() + to_matrix_world = rigid_obj.matrix_world @ rigid_obj.matrix_local.inverted() + matrix_world = to_matrix_world @ (m @ rigid_obj.matrix_local) + rigid_obj.parent = arm + rigid_obj.parent_type = "BONE" + rigid_obj.parent_bone = bone_name + rigid_obj.matrix_world = matrix_world + rigid_obj.scale = orig_scale + 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) + t, r, s = (m @ fake_child.matrix_local).decompose() + fake_child.location = t + fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) + + elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + m = target_bone.matrix @ target_bone.bone.matrix_local.inverted() + self.__rigid_body_matrix_map[rigid_obj] = m + t, r, s = (m @ rigid_obj.matrix_local).decompose() + rigid_obj.location = t + rigid_obj.rotation_euler = r.to_euler(rigid_obj.rotation_mode) + 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) + t, r, s = (m @ fake_child.matrix_local).decompose() + fake_child.location = t + fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) + + if "mmd_tools_rigid_track" not in target_bone.constraints: + empty = bpy.data.objects.new(name="mmd_bonetrack", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) + empty.matrix_world = target_bone.matrix + setattr(empty, Props.empty_display_type, "ARROWS") + setattr(empty, Props.empty_display_size, 0.1 * getattr(self.__root, Props.empty_display_size)) + empty.mmd_type = "TRACK_TARGET" + empty.hide_set(True) + empty.parent = self.temporaryGroupObject() + + rigid_obj.mmd_rigid.bone = bone_name + rigid_obj.constraints.remove(relation) + + self.__empty_parent_map[empty] = rigid_obj + + const_type = ("COPY_TRANSFORMS", "COPY_ROTATION")[rigid_type - 1] + const = target_bone.constraints.new(const_type) + const.mute = True + const.name = "mmd_tools_rigid_track" + const.target = empty + else: + empty = target_bone.constraints["mmd_tools_rigid_track"].target + 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) + # re-parenting + rigid_obj.mmd_rigid.bone = bone_name + rigid_obj.constraints.remove(relation) + self.__empty_parent_map[empty] = rigid_obj + # 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) + + rb.collision_shape = rigid.shape + + def __getRigidRange(self, obj): + return (Vector(obj.bound_box[0]) - Vector(obj.bound_box[6])).length + + def __createNonCollisionConstraint(self, nonCollisionJointTable): + total_len = len(nonCollisionJointTable) + if total_len < 1: + return + + start_time = time.time() + logging.debug("-" * 60) + logging.debug(" creating ncc, counts: %d", total_len) + + ncc_obj = bpyutils.createObject(name="ncc", object_data=None) + ncc_obj.location = [0, 0, 0] + setattr(ncc_obj, Props.empty_display_type, "ARROWS") + setattr(ncc_obj, Props.empty_display_size, 0.5 * getattr(self.__root, Props.empty_display_size)) + ncc_obj.mmd_type = "NON_COLLISION_CONSTRAINT" + ncc_obj.hide_render = True + ncc_obj.parent = self.temporaryGroupObject() + + bpy.ops.rigidbody.constraint_add(type="GENERIC") + rb = ncc_obj.rigid_body_constraint + rb.disable_collisions = True + + ncc_objs = bpyutils.duplicateObject(ncc_obj, total_len) + logging.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) + + def buildRigids(self, non_collision_distance_scale, collision_margin): + logging.debug("--------------------------------") + logging.debug(" Build riggings of rigid bodies") + logging.debug("--------------------------------") + rigid_objects = list(self.rigidBodies()) + rigid_object_groups = [[] for i in range(16)] + for i in rigid_objects: + rigid_object_groups[i.mmd_rigid.collision_group_number].append(i) + + jointMap = {} + for joint in self.joints(): + rbc = joint.rigid_body_constraint + if rbc is None: + continue + rbc.disable_collisions = False + jointMap[frozenset((rbc.object1, rbc.object2))] = joint + + logging.info("Creating non collision constraints") + # create non collision constraints + nonCollisionJointTable = [] + non_collision_pairs = set() + rigid_object_cnt = len(rigid_objects) + for obj_a in rigid_objects: + for n, ignore in enumerate(obj_a.mmd_rigid.collision_group_mask): + if not ignore: + continue + for obj_b in rigid_object_groups[n]: + if obj_a == obj_b: + continue + pair = frozenset((obj_a, obj_b)) + if pair in non_collision_pairs: + continue + if pair in jointMap: + joint = jointMap[pair] + joint.rigid_body_constraint.disable_collisions = True + else: + distance = (obj_a.location - obj_b.location).length + if distance < non_collision_distance_scale * (self.__getRigidRange(obj_a) + self.__getRigidRange(obj_b)) * 0.5: + 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) + self.updateRigid(i, collision_margin) + self.__createNonCollisionConstraint(nonCollisionJointTable) + return rigid_objects + + def buildJoints(self): + for i in self.joints(): + rbc = i.rigid_body_constraint + if rbc is None: + continue + m = self.__rigid_body_matrix_map.get(rbc.object1, None) + if m is None: + m = self.__rigid_body_matrix_map.get(rbc.object2, None) + if m is None: + continue + t, r, s = (m @ i.matrix_local).decompose() + i.location = t + i.rotation_euler = r.to_euler(i.rotation_mode) + + def __editPhysicsBones(self, editor: Callable[[bpy.types.EditBone], None], target_modes: Set[str]): + armature_object = self.armature() + + armature: bpy.types.Armature + with bpyutils.edit_object(armature_object) as armature: + 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 + if mmd_rigid.type not in target_modes: + continue + + bone_name: str = mmd_rigid.bone + edit_bone = edit_bones.get(bone_name) + if edit_bone is None: + continue + + editor(edit_bone) + + def disconnectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): + rna_prop_ui.rna_idprop_ui_create(edit_bone, "mmd_bone_use_connect", default=edit_bone.use_connect) + edit_bone.use_connect = False + + self.__editPhysicsBones(editor, {str(MODE_DYNAMIC)}) + + def connectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): + mmd_bone_use_connect_str: Optional[str] = edit_bone.get("mmd_bone_use_connect") + if mmd_bone_use_connect_str is None: + return + + if not edit_bone.use_connect: # wasn't it overwritten? + edit_bone.use_connect = bool(mmd_bone_use_connect_str) + 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 new file mode 100644 index 0000000..aaa707e --- /dev/null +++ b/core/mmd/core/morph.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import re +from typing import TYPE_CHECKING, Tuple, cast + +import bpy + +from .. import bpyutils, utils +from ..bpyutils import FnContext, FnObject, TransformConstraintOp + +if TYPE_CHECKING: + from .model import Model + + +class FnMorph: + def __init__(self, morph, model: "Model"): + self.__morph = morph + self.__rig = model + + @classmethod + def storeShapeKeyOrder(cls, obj, shape_key_names): + if len(shape_key_names) < 1: + return + assert FnContext.get_active_object(FnContext.ensure_context()) == obj + if obj.data.shape_keys is None: + bpy.ops.object.shape_key_add() + + def __move_to_bottom(key_blocks, name): + obj.active_shape_key_index = key_blocks.find(name) + bpy.ops.object.shape_key_move(type="BOTTOM") + + key_blocks = obj.data.shape_keys.key_blocks + for name in shape_key_names: + if name not in key_blocks: + obj.shape_key_add(name=name, from_mix=False) + elif len(key_blocks) > 1: + __move_to_bottom(key_blocks, name) + + @classmethod + def fixShapeKeyOrder(cls, obj, shape_key_names): + if len(shape_key_names) < 1: + return + assert FnContext.get_active_object(FnContext.ensure_context()) == obj + key_blocks = getattr(obj.data.shape_keys, "key_blocks", None) + if key_blocks is None: + return + for name in shape_key_names: + idx = key_blocks.find(name) + if idx < 0: + continue + obj.active_shape_key_index = idx + bpy.ops.object.shape_key_move(type="BOTTOM") + + @staticmethod + def get_morph_slider(rig): + return _MorphSlider(rig) + + @staticmethod + def category_guess(morph): + name_lower = morph.name.lower() + if "mouth" in name_lower: + morph.category = "MOUTH" + elif "eye" in name_lower: + if "brow" in name_lower: + morph.category = "EYEBROW" + else: + morph.category = "EYE" + + @classmethod + def load_morphs(cls, rig): + mmd_root = rig.rootObject().mmd_root + vertex_morphs = mmd_root.vertex_morphs + uv_morphs = mmd_root.uv_morphs + for obj in rig.meshes(): + for kb in getattr(obj.data.shape_keys, "key_blocks", ())[1:]: + if not kb.name.startswith("mmd_") and kb.name not in vertex_morphs: + item = vertex_morphs.add() + item.name = kb.name + item.name_e = kb.name + cls.category_guess(item) + for g, name, x in FnMorph.get_uv_morph_vertex_groups(obj): + if name not in uv_morphs: + item = uv_morphs.add() + item.name = item.name_e = name + item.data_type = "VERTEX_GROUP" + cls.category_guess(item) + + @staticmethod + def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str): + assert isinstance(mesh_object.data, bpy.types.Mesh) + + shape_keys = mesh_object.data.shape_keys + if shape_keys is None: + return + + key_blocks = shape_keys.key_blocks + if key_blocks and shape_key_name in key_blocks: + 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): + assert isinstance(mesh_object.data, bpy.types.Mesh) + + shape_keys = mesh_object.data.shape_keys + if shape_keys is None: + return + + key_blocks = shape_keys.key_blocks + + if src_name not in key_blocks: + return + + if dest_name in key_blocks: + FnObject.mesh_remove_shape_key(mesh_object, key_blocks[dest_name]) + + mesh_object.active_shape_key_index = key_blocks.find(src_name) + mesh_object.show_only_shape_key, last = True, mesh_object.show_only_shape_key + mesh_object.shape_key_add(name=dest_name, from_mix=True) + mesh_object.show_only_shape_key = last + 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"): + 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): + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name): + obj.vertex_groups.remove(vg) + + for vg_name in tuple(i[0].name for i in FnMorph.get_uv_morph_vertex_groups(obj, src_name)): + obj.vertex_groups.active = obj.vertex_groups[vg_name] + with bpy.context.temp_override(object=obj, window=bpy.context.window, region=bpy.context.region): + bpy.ops.object.vertex_group_copy() + obj.vertex_groups.active.name = vg_name.replace(src_name, dest_name) + + @staticmethod + def overwrite_bone_morphs_from_action_pose(armature_object): + armature = armature_object.id_data + + # Use animation_data and action instead of action_pose + if armature.animation_data is None or armature.animation_data.action is None: + logging.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name) + return + + action = armature.animation_data.action + pose_markers = action.pose_markers + + if not pose_markers: + return + + root = armature_object.parent + mmd_root = root.mmd_root + bone_morphs = mmd_root.bone_morphs + + utils.selectAObject(armature_object) + original_mode = bpy.context.object.mode + bpy.ops.object.mode_set(mode="POSE") + try: + for index, pose_marker in enumerate(pose_markers): + bone_morph = next(iter([m for m in bone_morphs if m.name == pose_marker.name]), None) + if bone_morph is None: + bone_morph = bone_morphs.add() + bone_morph.name = pose_marker.name + + bpy.ops.pose.select_all(action="SELECT") + bpy.ops.pose.transforms_clear() + + frame = pose_marker.frame + bpy.context.scene.frame_set(int(frame)) + + mmd_root.active_morph = bone_morphs.find(bone_morph.name) + bpy.ops.mmd_tools.apply_bone_morph() + + bpy.ops.pose.transforms_clear() + + finally: + bpy.ops.object.mode_set(mode=original_mode) + utils.selectAObject(root) + + @staticmethod + def clean_uv_morph_vertex_groups(obj): + # remove empty vertex groups of uv morphs + vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)} + vertex_groups = obj.vertex_groups + for v in obj.data.vertices: + for x in v.groups: + if x.group in vg_indices and x.weight > 0: + vg_indices.remove(x.group) + for i in sorted(vg_indices, reverse=True): + vg = vertex_groups[i] + m = obj.modifiers.get("mmd_bind%s" % hash(vg.name), None) + if m: + obj.modifiers.remove(m) + vertex_groups.remove(vg) + + @staticmethod + def get_uv_morph_offset_map(obj, morph): + offset_map = {} # offset_map[vertex_index] = offset_xyzw + if morph.data_type == "VERTEX_GROUP": + scale = morph.vertex_group_scale + axis_map = {g.index: x for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph.name)} + for v in obj.data.vertices: + i = v.index + for x in v.groups: + if x.group in axis_map and x.weight > 0: + axis, weight = axis_map[x.group], x.weight + d = offset_map.setdefault(i, [0, 0, 0, 0]) + d["XYZW".index(axis[1])] += -weight * scale if axis[0] == "-" else weight * scale + else: + for val in morph.data: + i = val.index + if i in offset_map: + offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset)] + else: + offset_map[i] = val.offset + return offset_map + + @staticmethod + def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"): + vertex_groups = obj.vertex_groups + morph_name = getattr(morph, "name", None) + if offset_axes: + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph_name, offset_axes): + vertex_groups.remove(vg) + if not morph_name or not offsets: + return + + axis_indices = tuple("XYZW".index(x) for x in offset_axes) or tuple(range(4)) + offset_map = FnMorph.get_uv_morph_offset_map(obj, morph) if offset_axes else {} + for data in offsets: + idx, offset = data.index, data.offset + for i in axis_indices: + offset_map.setdefault(idx, [0, 0, 0, 0])[i] += round(offset[i], 5) + + max_value = max(max(abs(x) for x in v) for v in offset_map.values() or ([0],)) + scale = morph.vertex_group_scale = max(abs(morph.vertex_group_scale), max_value) + for idx, offset in offset_map.items(): + for val, axis in zip(offset, "XYZW"): + if abs(val) > 1e-4: + vg_name = "UV_{0}{1}{2}".format(morph_name, "-" if val < 0 else "+", axis) + vg = vertex_groups.get(vg_name, None) or vertex_groups.new(name=vg_name) + vg.add(index=[idx], weight=abs(val) / scale, type="REPLACE") + + def update_mat_related_mesh(self, new_mesh=None): + for offset in self.__morph.data: + # Use the new_mesh if provided + meshObj = new_mesh + if new_mesh is None: + # Try to find the mesh by material name + meshObj = self.__rig.findMesh(offset.material) + + if meshObj is None: + # Given this point we need to loop through all the meshes + for mesh in self.__rig.meshes(): + if mesh.data.materials.find(offset.material) >= 0: + meshObj = mesh + break + + # Finally update the reference + if meshObj is not None: + offset.related_mesh = meshObj.data.name + + @staticmethod + def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object): + """Clean duplicated material_morphs and data from mmd_root_object.mmd_root.material_morphs[].data[]""" + mmd_root = mmd_root_object.mmd_root + + def morph_data_equals(l, r) -> bool: + return ( + l.related_mesh_data == r.related_mesh_data + and l.offset_type == r.offset_type + and l.material == r.material + and all(a == b for a, b in zip(l.diffuse_color, r.diffuse_color)) + and all(a == b for a, b in zip(l.specular_color, r.specular_color)) + and l.shininess == r.shininess + and all(a == b for a, b in zip(l.ambient_color, r.ambient_color)) + and all(a == b for a, b in zip(l.edge_color, r.edge_color)) + and l.edge_weight == r.edge_weight + and all(a == b for a, b in zip(l.texture_factor, r.texture_factor)) + and all(a == b for a, b in zip(l.sphere_texture_factor, r.sphere_texture_factor)) + and all(a == b for a, b in zip(l.toon_texture_factor, r.toon_texture_factor)) + ) + + def morph_equals(l, r) -> 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[] + for material_morph in mmd_root.material_morphs: + save_materil_morph_datas = [] + remove_material_morph_data_indices = [] + for index, material_morph_data in enumerate(material_morph.data): + if any(morph_data_equals(material_morph_data, saved_material_morph_data) for saved_material_morph_data in save_materil_morph_datas): + remove_material_morph_data_indices.append(index) + continue + save_materil_morph_datas.append(material_morph_data) + + for index in reversed(remove_material_morph_data_indices): + material_morph.data.remove(index) + + # Mark duplicated mmd_root.material_morphs[] + save_material_morphs = [] + remove_material_morph_names = [] + for material_morph in sorted(mmd_root.material_morphs, key=lambda m: m.name): + if any(morph_equals(material_morph, saved_material_morph) for saved_material_morph in save_material_morphs): + remove_material_morph_names.append(material_morph.name) + continue + + save_material_morphs.append(material_morph) + + # Remove marked mmd_root.material_morphs[] + for material_morph_name in remove_material_morph_names: + mmd_root.material_morphs.remove(mmd_root.material_morphs.find(material_morph_name)) + + +class _MorphSlider: + def __init__(self, model: "Model"): + self.__rig = model + + def placeholder(self, create=False, binded=False): + rig = self.__rig + root = rig.rootObject() + obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None) + if create and obj is None: + obj = bpy.data.objects.new(name=".placeholder", object_data=bpy.data.meshes.new(".placeholder")) + obj.mmd_type = "PLACEHOLDER" + obj.parent = root + FnContext.link_object(FnContext.ensure_context(), obj) + if obj and obj.data.shape_keys is None: + key = obj.shape_key_add(name="--- morph sliders ---") + key.mute = True + obj.active_shape_key_index = 0 + if binded and obj and obj.data.shape_keys.key_blocks[0].mute: + return None + return obj + + @property + def dummy_armature(self): + obj = self.placeholder() + return self.__dummy_armature(obj) if obj else None + + def __dummy_armature(self, obj, create=False): + arm = next((x for x in obj.children if x.mmd_type == "PLACEHOLDER" and x.type == "ARMATURE"), None) + if create and arm is None: + arm = bpy.data.objects.new(name=".dummy_armature", object_data=bpy.data.armatures.new(name=".dummy_armature")) + arm.mmd_type = "PLACEHOLDER" + arm.parent = obj + FnContext.link_object(FnContext.ensure_context(), arm) + + from .bone import FnBone + + FnBone.setup_special_bone_collections(arm) + return arm + + def get(self, morph_name): + obj = self.placeholder() + if obj is None: + return None + key_blocks = obj.data.shape_keys.key_blocks + if key_blocks[0].mute: + return None + return key_blocks.get(morph_name, None) + + def create(self): + self.__rig.loadMorphs() + obj = self.placeholder(create=True) + self.__load(obj, self.__rig.rootObject().mmd_root) + return obj + + def __load(self, obj, mmd_root): + attr_list = ("group", "vertex", "bone", "uv", "material") + morph_sliders = obj.data.shape_keys.key_blocks + for m in (x for attr in attr_list for x in getattr(mmd_root, attr + "_morphs", ())): + name = m.name + # if name[-1] == '\\': # fix driver's bug??? + # m.name = name = name + ' ' + if name and name not in morph_sliders: + obj.shape_key_add(name=name, from_mix=False) + + @staticmethod + def __driver_variables(id_data, path, index=-1): + d = id_data.driver_add(path, index) + variables = d.driver.variables + for x in variables: + variables.remove(x) + return d.driver, variables + + @staticmethod + def __add_single_prop(variables, id_obj, data_path, prefix): + var = variables.new() + var.name = f"{prefix}{len(variables)}" + var.type = "SINGLE_PROP" + target = var.targets[0] + target.id_type = "OBJECT" + target.id = id_obj + target.data_path = data_path + return var + + @staticmethod + def __shape_key_driver_check(key_block, resolve_path=False): + if resolve_path: + try: + key_block.id_data.path_resolve(key_block.path_from_id()) + except ValueError: + return False + if not key_block.id_data.animation_data: + return True + d = key_block.id_data.animation_data.drivers.find(key_block.path_from_id("value")) + if isinstance(d, int): # for Blender 2.76 or older + data_path = key_block.path_from_id("value") + 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): + from math import ceil, floor + + names_in_use = names_in_use or {} + rig = self.__rig + morph_sliders = self.placeholder() + morph_sliders = morph_sliders.data.shape_keys.key_blocks if morph_sliders else {} + for mesh_object in rig.meshes(): + for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast(Tuple[bpy.types.ShapeKey], ())): + if kb.name in names_in_use: + continue + + if kb.name.startswith("mmd_bind"): + kb.driver_remove("value") + ms = morph_sliders[kb.relative_key.name] + kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, floor(ms.value)), max(ms.slider_max, ceil(ms.value)) + kb.relative_key.value = ms.value + kb.relative_key.mute = False + FnObject.mesh_remove_shape_key(mesh_object, kb) + + elif kb.name in morph_sliders and self.__shape_key_driver_check(kb): + ms = morph_sliders[kb.name] + kb.driver_remove("value") + kb.slider_min, kb.slider_max = min(ms.slider_min, floor(kb.value)), max(ms.slider_max, ceil(kb.value)) + + for m in mesh_object.modifiers: # uv morph + if m.name.startswith("mmd_bind") and m.name not in names_in_use: + mesh_object.modifiers.remove(m) + + from .shader import _MaterialMorph + + for m in rig.materials(): + if m and m.node_tree: + for n in sorted((x for x in m.node_tree.nodes if x.name.startswith("mmd_bind")), key=lambda x: -x.location[0]): + _MaterialMorph.reset_morph_links(n) + m.node_tree.nodes.remove(n) + + attributes = set(TransformConstraintOp.min_max_attributes("LOCATION", "to")) + attributes |= set(TransformConstraintOp.min_max_attributes("ROTATION", "to")) + for b in rig.armature().pose.bones: + for c in b.constraints: + if c.name.startswith("mmd_bind") and c.name[:-4] not in names_in_use: + for attr in attributes: + c.driver_remove(attr) + b.constraints.remove(c) + + def unbind(self): + mmd_root = self.__rig.rootObject().mmd_root + + # after unbind, the weird lag problem will disappear. + mmd_root.morph_panel_show_settings = True + + for m in mmd_root.bone_morphs: + for d in m.data: + d.name = "" + for m in mmd_root.material_morphs: + for d in m.data: + d.name = "" + obj = self.placeholder() + if obj: + obj.data.shape_keys.key_blocks[0].mute = True + arm = self.__dummy_armature(obj) + if arm: + for b in arm.pose.bones: + if b.name.startswith("mmd_bind"): + b.driver_remove("location") + b.driver_remove("rotation_quaternion") + self.__cleanup() + + def bind(self): + rig = self.__rig + root = rig.rootObject() + armObj = rig.armature() + mmd_root = root.mmd_root + + # hide detail to avoid weird lag problem + mmd_root.morph_panel_show_settings = False + + obj = self.create() + arm = self.__dummy_armature(obj, create=True) + morph_sliders = obj.data.shape_keys.key_blocks + + # data gathering + group_map = {} + + shape_key_map = {} + uv_morph_map = {} + for mesh_object in rig.meshes(): + mesh_object.show_only_shape_key = False + key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ()) + for kb in key_blocks: + kb_name = kb.name + if kb_name not in morph_sliders: + continue + + if self.__shape_key_driver_check(kb, resolve_path=True): + name_bind, kb_bind = kb_name, kb + else: + name_bind = "mmd_bind%s" % hash(morph_sliders[kb_name]) + if name_bind not in key_blocks: + mesh_object.shape_key_add(name=name_bind, from_mix=False) + kb_bind = key_blocks[name_bind] + kb_bind.relative_key = kb + kb_bind.slider_min = -10 + kb_bind.slider_max = 10 + + data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"') + groups = [] + shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups)) + group_map.setdefault(("vertex_morphs", kb_name), []).append(groups) + + uv_layers = [l.name for l in mesh_object.data.uv_layers if not l.name.startswith("_")] + uv_layers += [""] * (5 - len(uv_layers)) + for vg, morph_name, axis in FnMorph.get_uv_morph_vertex_groups(mesh_object): + morph = mmd_root.uv_morphs.get(morph_name, None) + if morph is None or morph.data_type != "VERTEX_GROUP": + continue + + uv_layer = "_" + uv_layers[morph.uv_index] if axis[1] in "ZW" else uv_layers[morph.uv_index] + if uv_layer not in mesh_object.data.uv_layers: + continue + + name_bind = "mmd_bind%s" % hash(vg.name) + 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 + mod.axis_u, mod.axis_v = ("Y", "X") if axis[1] in "YW" else ("X", "Y") + mod.uv_layer = uv_layer + name_bind = "mmd_bind%s" % hash(morph_name) + mod.object_from = mod.object_to = arm + if axis[0] == "-": + mod.bone_from, mod.bone_to = "mmd_bind_ctrl_base", name_bind + else: + mod.bone_from, mod.bone_to = name_bind, "mmd_bind_ctrl_base" + + bone_offset_map = {} + with bpyutils.edit_object(arm) as data: + from .bone import FnBone + + edit_bones = data.edit_bones + + def __get_bone(name, parent): + b = edit_bones.get(name, None) or edit_bones.new(name=name) + b.head = (0, 0, 0) + b.tail = (0, 0, 1) + b.use_deform = False + b.parent = parent + return b + + for m in mmd_root.bone_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + for d in m.data: + if not d.bone: + d.name = "" + continue + d.name = name_bind = f"mmd_bind{hash(d)}" + b = FnBone.set_edit_bone_to_shadow(__get_bone(name_bind, None)) + groups = [] + bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups) + group_map.setdefault(("bone_morphs", m.name), []).append(groups) + + ctrl_base = FnBone.set_edit_bone_to_dummy(__get_bone("mmd_bind_ctrl_base", None)) + for m in mmd_root.uv_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + 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 = [] + 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.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 = {} + for m in mmd_root.material_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + groups = [] + group_map.setdefault(("material_morphs", m.name), []).append(groups) + material_offset_map.setdefault("group_dict", {})[m.name] = (data_path, groups) + for d in m.data: + d.name = name_bind = f"mmd_bind{hash(d)}" + # add '#' before material name to avoid conflict with group_dict + table = material_offset_map.setdefault("#" + d.material, ([], [])) + table[1 if d.offset_type == "ADD" else 0].append((m.name, d, name_bind)) + + 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) + morph_name = m.name.replace('"', '\\"') + morph_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + for d in m.data: + data_name = d.name.replace('"', '\\"') + factor_path = f'mmd_root.group_morphs["{morph_name}"].data["{data_name}"].factor' + for groups in group_map.get((d.morph_type, d.name), ()): + groups.append((m.name, morph_path, factor_path)) + + self.__cleanup(shape_key_map.keys() | bone_offset_map.keys() | uv_morph_map.keys()) + + def __config_groups(variables, expression, groups): + for g_name, morph_path, factor_path in groups: + var = self.__add_single_prop(variables, obj, morph_path, "g") + fvar = self.__add_single_prop(variables, root, factor_path, "w") + expression = f"{expression}+{var.name}*{fvar.name}" + return expression + + # vertex morphs + for kb_bind, morph_data_path, groups in (i for l in shape_key_map.values() for i in l): + driver, variables = self.__driver_variables(kb_bind, "value") + var = self.__add_single_prop(variables, obj, morph_data_path, "v") + if kb_bind.name.startswith("mmd_bind"): + driver.expression = f"-({__config_groups(variables, var.name, groups)})" + kb_bind.relative_key.mute = True + else: + driver.expression = __config_groups(variables, var.name, groups) + kb_bind.mute = False + + # bone morphs + def __config_bone_morph(constraints, map_type, attributes, val, val_str): + c_name = f"mmd_bind{hash(data)}.{map_type[:3]}" + c = TransformConstraintOp.create(constraints, c_name, map_type) + TransformConstraintOp.update_min_max(c, val, None) + c.show_expanded = False + c.target = arm + c.subtarget = bname + for attr in attributes: + driver, variables = self.__driver_variables(armObj, c.path_from_id(attr)) + var = self.__add_single_prop(variables, obj, morph_data_path, "b") + expression = __config_groups(variables, var.name, groups) + sign = "-" if attr.startswith("to_min") else "" + driver.expression = f"{sign}{val_str}*({expression})" + + from math import pi + + attributes_rot = TransformConstraintOp.min_max_attributes("ROTATION", "to") + attributes_loc = TransformConstraintOp.min_max_attributes("LOCATION", "to") + for morph_name, data, bname, morph_data_path, groups in bone_offset_map.values(): + b = arm.pose.bones[bname] + b.location = data.location + b.rotation_quaternion = data.rotation.__class__(*data.rotation.to_axis_angle()) # Fix for consistency + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + pb = armObj.pose.bones[data.bone] + __config_bone_morph(pb.constraints, "ROTATION", attributes_rot, pi, "pi") + __config_bone_morph(pb.constraints, "LOCATION", attributes_loc, 100, "100") + + # uv morphs + # HACK: workaround for Blender 2.80+, data_path can't be properly detected (Save & Reopen file also works) + root.parent, root.parent, root.matrix_parent_inverse = arm, root.parent, root.matrix_parent_inverse.copy() + b = arm.pose.bones["mmd_bind_ctrl_base"] + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + for bname, data_path, scale_path, groups in (i for l in uv_morph_map.values() for i in l): + b = arm.pose.bones[bname] + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + driver, variables = self.__driver_variables(b, "location", index=0) + var = self.__add_single_prop(variables, obj, data_path, "u") + fvar = self.__add_single_prop(variables, root, scale_path, "s") + driver.expression = f"({__config_groups(variables, var.name, groups)})*{fvar.name}" + + # material morphs + from .shader import _MaterialMorph + + group_dict = material_offset_map.get("group_dict", {}) + + def __config_material_morph(mat, morph_list): + nodes = _MaterialMorph.setup_morph_nodes(mat, tuple(x[1] for x in morph_list)) + for (morph_name, data, name_bind), node in zip(morph_list, nodes): + node.label, node.name = morph_name, name_bind + data_path, groups = group_dict[morph_name] + driver, variables = self.__driver_variables(mat.node_tree, node.inputs[0].path_from_id("default_value")) + var = self.__add_single_prop(variables, obj, data_path, "m") + driver.expression = "%s" % __config_groups(variables, var.name, groups) + + 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.") + mul_list, add_list = [], [] + else: + mat_name = "#" + mat.name + mul_list, add_list = material_offset_map.get(mat_name, ([], [])) + morph_list = tuple(mul_all + mul_list + add_all + add_list) + __config_material_morph(mat, morph_list) + mat_edge = bpy.data.materials.get("mmd_edge." + mat.name, None) + if mat_edge: + __config_material_morph(mat_edge, morph_list) + + morph_sliders[0].mute = False + + +class MigrationFnMorph: + @staticmethod + def update_mmd_morph(): + from .material import FnMaterial + + for root in bpy.data.objects: + if root.mmd_type != "ROOT": + continue + + for mat_morph in root.mmd_root.material_morphs: + for morph_data in mat_morph.data: + if morph_data.material_data is not None: + # SUPPORT_UNTIL: 5 LTS + # The material_id is also no longer used, but for compatibility with older version mmd_tools, keep it. + if "material_id" not in morph_data.material_data.mmd_material or "material_id" not in morph_data or morph_data.material_data.mmd_material["material_id"] == morph_data["material_id"]: + # In the new version, the related_mesh property is no longer used. + # Explicitly remove this property to avoid misuse. + if "related_mesh" in morph_data: + del morph_data["related_mesh"] + continue + + else: + # Compat case. The new version mmd_tools saved. And old version mmd_tools edit. Then new version mmd_tools load again. + # Go update path. + pass + + morph_data.material_data = None + if "material_id" in morph_data: + mat_id = morph_data["material_id"] + if mat_id != -1: + fnMat = FnMaterial.from_material_id(mat_id) + if fnMat: + morph_data.material_data = fnMat.material + else: + morph_data["material_id"] = -1 + + morph_data.related_mesh_data = None + if "related_mesh" in morph_data: + related_mesh = morph_data["related_mesh"] + del morph_data["related_mesh"] + if related_mesh != "" and related_mesh in bpy.data.meshes: + morph_data.related_mesh_data = bpy.data.meshes[related_mesh] + + @staticmethod + def ensure_material_id_not_conflict(): + mat_ids_set = set() + + # The reference library properties cannot be modified and bypassed in advance. + need_update_mat = [] + for mat in bpy.data.materials: + if mat.mmd_material.material_id < 0: + continue + if mat.library is not None: + mat_ids_set.add(mat.mmd_material.material_id) + else: + need_update_mat.append(mat) + + for mat in need_update_mat: + if mat.mmd_material.material_id in mat_ids_set: + mat.mmd_material.material_id = max(mat_ids_set) + 1 + mat_ids_set.add(mat.mmd_material.material_id) + + @staticmethod + def compatible_with_old_version_mmd_tools(): + MigrationFnMorph.ensure_material_id_not_conflict() + + for root in bpy.data.objects: + if root.mmd_type != "ROOT": + continue + + for mat_morph in root.mmd_root.material_morphs: + for morph_data in mat_morph.data: + morph_data["related_mesh"] = morph_data.related_mesh + + if morph_data.material_data is None: + morph_data.material_id = -1 + else: + morph_data.material_id = morph_data.material_data.mmd_material.material_id diff --git a/core/mmd/core/pmx/__init__.py b/core/mmd/core/pmx/__init__.py new file mode 100644 index 0000000..7de70bd --- /dev/null +++ b/core/mmd/core/pmx/__init__.py @@ -0,0 +1,1625 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import os +import struct + + +class InvalidFileError(Exception): + pass +class UnsupportedVersionError(Exception): + pass + +class FileStream: + def __init__(self, path, file_obj, pmx_header): + self.__path = path + self.__file_obj = file_obj + self.__header = pmx_header + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def path(self): + return self.__path + + def header(self): + if self.__header is None: + raise Exception + return self.__header + + def setHeader(self, pmx_header): + self.__header = pmx_header + + def close(self): + if self.__file_obj is not None: + logging.debug('close the file("%s")', self.__path) + self.__file_obj.close() + self.__file_obj = None + +class FileReadStream(FileStream): + def __init__(self, path, pmx_header=None): + self.__fin = open(path, 'rb') + FileStream.__init__(self, path, self.__fin, pmx_header) + + def __readIndex(self, size, typedict): + index = None + if size in typedict : + index, = struct.unpack(typedict[size], self.__fin.read(size)) + else: + raise ValueError('invalid data size %s'%str(size)) + return index + + def __readSignedIndex(self, size): + return self.__readIndex(size, { 1 :"'%self.charset + +class Coordinate: + """ """ + def __init__(self, xAxis, zAxis): + self.x_axis = xAxis + self.z_axis = zAxis + +class Header: + PMX_SIGN = b'PMX ' + VERSION = 2.0 + def __init__(self, model=None): + self.sign = self.PMX_SIGN + self.version = 0 + + self.encoding = Encoding('utf-16-le') + self.additional_uvs = 0 + + self.vertex_index_size = 1 + self.texture_index_size = 1 + self.material_index_size = 1 + self.bone_index_size = 1 + self.morph_index_size = 1 + self.rigid_index_size = 1 + + if model is not None: + self.updateIndexSizes(model) + + def updateIndexSizes(self, model): + self.vertex_index_size = self.__getIndexSize(len(model.vertices), False) + self.texture_index_size = self.__getIndexSize(len(model.textures), True) + self.material_index_size = self.__getIndexSize(len(model.materials), True) + self.bone_index_size = self.__getIndexSize(len(model.bones), True) + self.morph_index_size = self.__getIndexSize(len(model.morphs), True) + self.rigid_index_size = self.__getIndexSize(len(model.rigids), True) + + @staticmethod + def __getIndexSize(num, signed): + s = 1 + if signed: + s = 2 + if (1<<8)/s > num: + return 1 + elif (1<<16)/s > num: + return 2 + else: + return 4 + + def load(self, fs): + logging.info('loading pmx header information...') + self.sign = fs.readBytes(4) + logging.debug('File signature is %s', self.sign) + if self.sign[:3] != self.PMX_SIGN[:3]: + logging.info('File signature is invalid') + logging.error('This file is unsupported format, or corrupt file.') + raise InvalidFileError('File signature is invalid.') + self.version = fs.readFloat() + logging.info('pmx format version: %f', self.version) + if self.version != self.VERSION: + logging.error('PMX version %.1f is unsupported', self.version) + raise UnsupportedVersionError('unsupported PMX version: %.1f'%self.version) + if fs.readByte() != 8 or self.sign[3] != self.PMX_SIGN[3]: + logging.warning(' * This file might be corrupted.') + self.encoding = Encoding(fs.readByte()) + self.additional_uvs = fs.readByte() + self.vertex_index_size = fs.readByte() + self.texture_index_size = fs.readByte() + self.material_index_size = fs.readByte() + self.bone_index_size = fs.readByte() + self.morph_index_size = fs.readByte() + self.rigid_index_size = fs.readByte() + + logging.info('----------------------------') + logging.info('pmx header information') + logging.info('----------------------------') + logging.info('pmx version: %.1f', self.version) + logging.info('encoding: %s', str(self.encoding)) + logging.info('number of uvs: %d', self.additional_uvs) + logging.info('vertex index size: %d byte(s)', self.vertex_index_size) + logging.info('texture index: %d byte(s)', self.texture_index_size) + logging.info('material index: %d byte(s)', self.material_index_size) + logging.info('bone index: %d byte(s)', self.bone_index_size) + logging.info('morph index: %d byte(s)', self.morph_index_size) + logging.info('rigid index: %d byte(s)', self.rigid_index_size) + logging.info('----------------------------') + + def save(self, fs): + fs.writeBytes(self.PMX_SIGN) + fs.writeFloat(self.VERSION) + fs.writeByte(8) + fs.writeByte(self.encoding.index) + fs.writeByte(self.additional_uvs) + fs.writeByte(self.vertex_index_size) + fs.writeByte(self.texture_index_size) + fs.writeByte(self.material_index_size) + fs.writeByte(self.bone_index_size) + fs.writeByte(self.morph_index_size) + fs.writeByte(self.rigid_index_size) + + def __repr__(self): + return '
'%( + str(self.encoding), + self.additional_uvs, + self.vertex_index_size, + self.texture_index_size, + self.material_index_size, + self.bone_index_size, + self.morph_index_size, + self.rigid_index_size, + ) + +class Model: + def __init__(self): + self.filepath = '' + self.header = None + + self.name = '' + self.name_e = '' + self.comment = '' + self.comment_e = '' + + self.vertices = [] + self.faces = [] + self.textures = [] + self.materials = [] + self.bones = [] + self.morphs = [] + + self.display = [] + dsp_root = Display() + dsp_root.isSpecial = True + dsp_root.name = 'Root' + dsp_root.name_e = 'Root' + self.display.append(dsp_root) + dsp_face = Display() + dsp_face.isSpecial = True + dsp_face.name = '表情' + dsp_face.name_e = 'Facial' + self.display.append(dsp_face) + + self.rigids = [] + self.joints = [] + + def load(self, fs): + self.filepath = fs.path() + self.header = fs.header() + + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.comment = fs.readStr() + self.comment_e = fs.readStr() + + logging.info('Model name: %s', self.name) + logging.info('Model name(english): %s', self.name_e) + logging.info('Comment:%s', self.comment) + logging.info('Comment(english):%s', self.comment_e) + + logging.info('') + logging.info('------------------------------') + logging.info('Load Vertices') + logging.info('------------------------------') + num_vertices = fs.readInt() + self.vertices = [] + for i in range(num_vertices): + v = Vertex() + v.load(fs) + self.vertices.append(v) + logging.info('----- Loaded %d vertices', len(self.vertices)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Faces') + logging.info('------------------------------') + num_faces = fs.readInt() + self.faces = [] + for i in range(int(num_faces/3)): + f1 = fs.readVertexIndex() + f2 = fs.readVertexIndex() + f3 = fs.readVertexIndex() + self.faces.append((f3, f2, f1)) + logging.info(' Load %d faces', len(self.faces)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Textures') + logging.info('------------------------------') + num_textures = fs.readInt() + self.textures = [] + for i in range(num_textures): + t = Texture() + t.load(fs) + self.textures.append(t) + logging.info('Texture %d: %s', i, t.path) + logging.info(' ----- Loaded %d textures', len(self.textures)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Materials') + logging.info('------------------------------') + num_materials = fs.readInt() + self.materials = [] + for i in range(num_materials): + m = Material() + m.load(fs, num_textures) + self.materials.append(m) + + logging.info('Material %d: %s', i, m.name) + logging.debug(' Name(english): %s', m.name_e) + logging.debug(' Comment: %s', m.comment) + logging.debug(' Vertex Count: %d', m.vertex_count) + logging.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse) + logging.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular) + logging.debug(' Shininess: %f', m.shininess) + logging.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient) + logging.debug(' Double Sided: %s', str(m.is_double_sided)) + logging.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow)) + logging.debug(' Self Shadow: %s', str(m.enabled_self_shadow)) + logging.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map)) + logging.debug(' Edge: %s', str(m.enabled_toon_edge)) + logging.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color) + logging.debug(' Edge Size: %.2f', m.edge_size) + if m.texture != -1: + logging.debug(' Texture Index: %d', m.texture) + else: + logging.debug(' Texture: None') + if m.sphere_texture != -1: + logging.debug(' Sphere Texture Index: %d', m.sphere_texture) + logging.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode) + else: + logging.debug(' Sphere Texture: None') + logging.debug('') + + logging.info('----- Loaded %d materials.', len(self.materials)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Bones') + logging.info('------------------------------') + num_bones = fs.readInt() + self.bones = [] + for i in range(num_bones): + b = Bone() + b.load(fs) + self.bones.append(b) + + logging.info('Bone %d: %s', i, b.name) + logging.debug(' Name(english): %s', b.name_e) + logging.debug(' Location: (%f, %f, %f)', *b.location) + logging.debug(' displayConnection: %s', str(b.displayConnection)) + logging.debug(' Parent: %s', str(b.parent)) + logging.debug(' Transform Order: %s', str(b.transform_order)) + logging.debug(' Rotatable: %s', str(b.isRotatable)) + logging.debug(' Movable: %s', str(b.isMovable)) + logging.debug(' Visible: %s', str(b.visible)) + logging.debug(' Controllable: %s', str(b.isControllable)) + logging.debug(' Additional Location: %s', str(b.hasAdditionalLocation)) + logging.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate)) + if b.additionalTransform is not None: + logging.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform) + logging.debug(' IK: %s', str(b.isIK)) + if b.isIK: + logging.debug(' Unit Angle: %f', b.rotationConstraint) + logging.debug(' Target: %d', b.target) + for j, link in enumerate(b.ik_links): + logging.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle)) + logging.debug('') + logging.info('----- Loaded %d bones.', len(self.bones)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Morphs') + logging.info('------------------------------') + num_morph = fs.readInt() + self.morphs = [] + display_categories = {0: 'System', 1: 'Eyebrow', 2: 'Eye', 3: 'Mouth', 4: 'Other'} + for i in range(num_morph): + m = Morph.create(fs) + self.morphs.append(m) + + logging.info('%s %d: %s', m.__class__.__name__, i, m.name) + logging.debug(' Name(english): %s', m.name_e) + logging.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category) + logging.debug('') + logging.info('----- Loaded %d morphs.', len(self.morphs)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Display Items') + logging.info('------------------------------') + num_disp = fs.readInt() + self.display = [] + for i in range(num_disp): + d = Display() + d.load(fs) + self.display.append(d) + + logging.info('Display Item %d: %s', i, d.name) + logging.debug(' Name(english): %s', d.name_e) + logging.debug('') + logging.info('----- Loaded %d display items.', len(self.display)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Rigid Bodies') + logging.info('------------------------------') + num_rigid = fs.readInt() + self.rigids = [] + rigid_types = {0: 'Sphere', 1: 'Box', 2: 'Capsule'} + rigid_modes = {0: 'Static', 1: 'Dynamic', 2: 'Dynamic(track to bone)'} + for i in range(num_rigid): + r = Rigid() + r.load(fs) + self.rigids.append(r) + logging.info('Rigid Body %d: %s', i, r.name) + logging.debug(' Name(english): %s', r.name_e) + logging.debug(' Type: %s', rigid_types[r.type]) + logging.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode) + logging.debug(' Related bone: %s', r.bone) + logging.debug(' Collision group: %d', r.collision_group_number) + logging.debug(' Collision group mask: 0x%x', r.collision_group_mask) + logging.debug(' Size: (%f, %f, %f)', *r.size) + logging.debug(' Location: (%f, %f, %f)', *r.location) + logging.debug(' Rotation: (%f, %f, %f)', *r.rotation) + logging.debug(' Mass: %f', r.mass) + logging.debug(' Bounce: %f', r.bounce) + logging.debug(' Friction: %f', r.friction) + logging.debug('') + + logging.info('----- Loaded %d rigid bodies.', len(self.rigids)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Joints') + logging.info('------------------------------') + num_joints = fs.readInt() + self.joints = [] + for i in range(num_joints): + j = Joint() + j.load(fs) + self.joints.append(j) + + logging.info('Joint %d: %s', i, j.name) + logging.debug(' Name(english): %s', j.name_e) + logging.debug(' Rigid A: %s', j.src_rigid) + logging.debug(' Rigid B: %s', j.dest_rigid) + logging.debug(' Location: (%f, %f, %f)', *j.location) + logging.debug(' Rotation: (%f, %f, %f)', *j.rotation) + logging.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location)) + logging.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation)) + logging.debug(' Spring: (%f, %f, %f)', *j.spring_constant) + logging.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant) + logging.debug('') + + logging.info('----- Loaded %d joints.', len(self.joints)) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeStr(self.comment) + fs.writeStr(self.comment_e) + + logging.info('''exportings pmx model data... +name: %s +name(english): %s +comment: +%s +comment(english): +%s +''', self.name, self.name_e, self.comment, self.comment_e) + + logging.info('exporting vertices... %d', len(self.vertices)) + fs.writeInt(len(self.vertices)) + for i in self.vertices: + i.save(fs) + logging.info('finished exporting vertices.') + + logging.info('exporting faces... %d', len(self.faces)) + fs.writeInt(len(self.faces)*3) + for f3, f2, f1 in self.faces: + fs.writeVertexIndex(f1) + fs.writeVertexIndex(f2) + fs.writeVertexIndex(f3) + logging.info('finished exporting faces.') + + logging.info('exporting textures... %d', len(self.textures)) + fs.writeInt(len(self.textures)) + for i in self.textures: + i.save(fs) + logging.info('finished exporting textures.') + + logging.info('exporting materials... %d', len(self.materials)) + fs.writeInt(len(self.materials)) + for i in self.materials: + i.save(fs) + logging.info('finished exporting materials.') + + logging.info('exporting bones... %d', len(self.bones)) + fs.writeInt(len(self.bones)) + for i in self.bones: + i.save(fs) + logging.info('finished exporting bones.') + + logging.info('exporting morphs... %d', len(self.morphs)) + fs.writeInt(len(self.morphs)) + for i in self.morphs: + i.save(fs) + logging.info('finished exporting morphs.') + + logging.info('exporting display items... %d', len(self.display)) + fs.writeInt(len(self.display)) + for i in self.display: + i.save(fs) + logging.info('finished exporting display items.') + + logging.info('exporting rigid bodies... %d', len(self.rigids)) + fs.writeInt(len(self.rigids)) + for i in self.rigids: + i.save(fs) + logging.info('finished exporting rigid bodies.') + + logging.info('exporting joints... %d', len(self.joints)) + fs.writeInt(len(self.joints)) + for i in self.joints: + i.save(fs) + logging.info('finished exporting joints.') + logging.info('finished exporting the model.') + + + def __repr__(self): + return ''%( + self.name, + self.name_e, + self.comment, + self.comment_e, + str(self.textures), + ) + +class Vertex: + def __init__(self): + self.co = [0.0, 0.0, 0.0] + self.normal = [0.0, 0.0, 0.0] + self.uv = [0.0, 0.0] + self.additional_uvs = [] + self.weight = None + self.edge_scale = 1 + + def __repr__(self): + return ''%( + str(self.co), + str(self.normal), + str(self.uv), + str(self.additional_uvs), + str(self.weight), + str(self.edge_scale), + ) + + def load(self, fs): + self.co = fs.readVector(3) + self.normal = fs.readVector(3) + self.uv = fs.readVector(2) + self.additional_uvs = [] + for i in range(fs.header().additional_uvs): + self.additional_uvs.append(fs.readVector(4)) + self.weight = BoneWeight() + self.weight.load(fs) + self.edge_scale = fs.readFloat() + + def save(self, fs): + fs.writeVector(self.co) + fs.writeVector(self.normal) + fs.writeVector(self.uv) + for i in self.additional_uvs: + fs.writeVector(i) + for i in range(fs.header().additional_uvs-len(self.additional_uvs)): + fs.writeVector((0,0,0,0)) + self.weight.save(fs) + fs.writeFloat(self.edge_scale) + +class BoneWeightSDEF: + def __init__(self, weight=0, c=None, r0=None, r1=None): + self.weight = weight + self.c = c + self.r0 = r0 + self.r1 = r1 + +class BoneWeight: + BDEF1 = 0 + BDEF2 = 1 + BDEF4 = 2 + SDEF = 3 + + TYPES = [ + (BDEF1, 'BDEF1'), + (BDEF2, 'BDEF2'), + (BDEF4, 'BDEF4'), + (SDEF, 'SDEF'), + ] + + def __init__(self): + self.bones = [] + self.weights = [] + self.type = self.BDEF1 + + def convertIdToName(self, type_id): + t = list(filter(lambda x: x[0]==type_id, self.TYPES)) + if len(t) > 0: + return t[0][1] + else: + return None + + def convertNameToId(self, type_name): + t = list(filter(lambda x: x[1]==type_name, self.TYPES)) + if len(t) > 0: + return t[0][0] + else: + return None + + def load(self, fs): + self.type = fs.readByte() + self.bones = [] + self.weights = [] + + if self.type == self.BDEF1: + self.bones.append(fs.readBoneIndex()) + elif self.type == self.BDEF2: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights.append(fs.readFloat()) + elif self.type == self.BDEF4: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights = fs.readVector(4) + elif self.type == self.SDEF: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights = BoneWeightSDEF() + self.weights.weight = fs.readFloat() + self.weights.c = fs.readVector(3) + self.weights.r0 = fs.readVector(3) + self.weights.r1 = fs.readVector(3) + else: + raise ValueError('invalid weight type %s'%str(self.type)) + + def save(self, fs): + fs.writeByte(self.type) + if self.type == self.BDEF1: + fs.writeBoneIndex(self.bones[0]) + elif self.type == self.BDEF2: + for i in range(2): + fs.writeBoneIndex(self.bones[i]) + fs.writeFloat(self.weights[0]) + elif self.type == self.BDEF4: + for i in range(4): + fs.writeBoneIndex(self.bones[i]) + for i in range(4): + fs.writeFloat(self.weights[i]) + elif self.type == self.SDEF: + for i in range(2): + fs.writeBoneIndex(self.bones[i]) + if not isinstance(self.weights, BoneWeightSDEF): + raise ValueError + fs.writeFloat(self.weights.weight) + fs.writeVector(self.weights.c) + fs.writeVector(self.weights.r0) + fs.writeVector(self.weights.r1) + else: + raise ValueError('invalid weight type %s'%str(self.type)) + + +class Texture: + def __init__(self): + self.path = '' + + def __repr__(self): + return ''%str(self.path) + + def load(self, fs): + self.path = fs.readStr() + self.path = self.path.replace('\\', os.path.sep) + if not os.path.isabs(self.path): + self.path = os.path.normpath(os.path.join(os.path.dirname(fs.path()), self.path)) + + def save(self, fs): + try: + relPath = os.path.relpath(self.path, os.path.dirname(fs.path())) + except ValueError: + relPath = self.path + relPath = relPath.replace(os.path.sep, '\\') # always save using windows path conventions + logging.info('writing to pmx file the relative texture path: %s', relPath) + fs.writeStr(relPath) + +class SharedTexture(Texture): + def __init__(self): + self.number = 0 + self.prefix = '' + +class Material: + SPHERE_MODE_OFF = 0 + SPHERE_MODE_MULT = 1 + SPHERE_MODE_ADD = 2 + SPHERE_MODE_SUBTEX = 3 + + def __init__(self): + self.name = '' + self.name_e = '' + + self.diffuse = [] + self.specular = [] + self.shininess = 0 + self.ambient = [] + + self.is_double_sided = True + self.enabled_drop_shadow = True + self.enabled_self_shadow_map = True + self.enabled_self_shadow = True + self.enabled_toon_edge = False + + self.edge_color = [] + self.edge_size = 1 + + self.texture = -1 + self.sphere_texture = -1 + self.sphere_texture_mode = 0 + self.is_shared_toon_texture = True + self.toon_texture = 0 + + self.comment = '' + self.vertex_count = 0 + + def __repr__(self): + return ''%( + self.name, + self.name_e, + str(self.diffuse), + str(self.specular), + str(self.shininess), + str(self.ambient), + str(self.is_double_sided), + str(self.enabled_drop_shadow), + str(self.enabled_self_shadow_map), + str(self.enabled_self_shadow), + str(self.enabled_toon_edge), + str(self.edge_color), + str(self.edge_size), + str(self.texture), + str(self.sphere_texture), + str(self.toon_texture), + str(self.comment),) + + def load(self, fs, num_textures): + def __tex_index(index): + return index if 0 <= index < num_textures else -1 + + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.diffuse = fs.readVector(4) + self.specular = fs.readVector(3) + self.shininess = fs.readFloat() + self.ambient = fs.readVector(3) + + flags = fs.readByte() + self.is_double_sided = bool(flags & 1) + self.enabled_drop_shadow = bool(flags & 2) + self.enabled_self_shadow_map = bool(flags & 4) + self.enabled_self_shadow = bool(flags & 8) + self.enabled_toon_edge = bool(flags & 16) + + self.edge_color = fs.readVector(4) + self.edge_size = fs.readFloat() + + self.texture = __tex_index(fs.readTextureIndex()) + self.sphere_texture = __tex_index(fs.readTextureIndex()) + self.sphere_texture_mode = fs.readSignedByte() + + self.is_shared_toon_texture = fs.readSignedByte() + self.is_shared_toon_texture = (self.is_shared_toon_texture == 1) + if self.is_shared_toon_texture: + self.toon_texture = fs.readSignedByte() + else: + self.toon_texture = __tex_index(fs.readTextureIndex()) + + self.comment = fs.readStr() + self.vertex_count = fs.readInt() + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeVector(self.diffuse) + fs.writeVector(self.specular) + fs.writeFloat(self.shininess) + fs.writeVector(self.ambient) + + flags = 0 + flags |= int(self.is_double_sided) + flags |= int(self.enabled_drop_shadow) << 1 + flags |= int(self.enabled_self_shadow_map) << 2 + flags |= int(self.enabled_self_shadow) << 3 + flags |= int(self.enabled_toon_edge) << 4 + fs.writeByte(flags) + + fs.writeVector(self.edge_color) + fs.writeFloat(self.edge_size) + + fs.writeTextureIndex(self.texture) + fs.writeTextureIndex(self.sphere_texture) + fs.writeSignedByte(self.sphere_texture_mode) + + if self.is_shared_toon_texture: + fs.writeSignedByte(1) + fs.writeSignedByte(self.toon_texture) + else: + fs.writeSignedByte(0) + fs.writeTextureIndex(self.toon_texture) + + fs.writeStr(self.comment) + fs.writeInt(self.vertex_count) + + +class Bone: + def __init__(self): + self.name = '' + self.name_e = '' + + self.location = [] + self.parent = None + self.transform_order = 0 + + # 接続先表示方法 + # 座標オフセット(float3)または、boneIndex(int) + self.displayConnection = -1 + + self.isRotatable = True + self.isMovable = True + self.visible = True + self.isControllable = True + + self.isIK = False + + # 回転付与 + self.hasAdditionalRotate = False + + # 移動付与 + self.hasAdditionalLocation = False + + # 回転付与および移動付与の付与量 + self.additionalTransform = None + + # 軸固定 + # 軸ベクトルfloat3 + self.axis = None + + # ローカル軸 + self.localCoordinate = None + + self.transAfterPhis = False + + # 外部親変形 + self.externalTransKey = None + + # 以下IKボーンのみ有効な変数 + self.target = None + self.loopCount = 8 + # IKループ計三時の1回あたりの制限角度(ラジアン) + self.rotationConstraint = 0.03 + + # IKLinkオブジェクトの配列 + self.ik_links = [] + + def __repr__(self): + return ''%( + self.name, + self.name_e,) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.location = fs.readVector(3) + self.parent = fs.readBoneIndex() + self.transform_order = fs.readInt() + + flags = fs.readShort() + if flags & 0x0001: + self.displayConnection = fs.readBoneIndex() + else: + self.displayConnection = fs.readVector(3) + + self.isRotatable = ((flags & 0x0002) != 0) + self.isMovable = ((flags & 0x0004) != 0) + self.visible = ((flags & 0x0008) != 0) + self.isControllable = ((flags & 0x0010) != 0) + + self.isIK = ((flags & 0x0020) != 0) + + self.hasAdditionalRotate = ((flags & 0x0100) != 0) + self.hasAdditionalLocation = ((flags & 0x0200) != 0) + if self.hasAdditionalRotate or self.hasAdditionalLocation: + t = fs.readBoneIndex() + v = fs.readFloat() + self.additionalTransform = (t, v) + else: + self.additionalTransform = None + + + if flags & 0x0400: + self.axis = fs.readVector(3) + else: + self.axis = None + + if flags & 0x0800: + xaxis = fs.readVector(3) + zaxis = fs.readVector(3) + self.localCoordinate = Coordinate(xaxis, zaxis) + else: + self.localCoordinate = None + + self.transAfterPhis = ((flags & 0x1000) != 0) + + if flags & 0x2000: + self.externalTransKey = fs.readInt() + else: + self.externalTransKey = None + + if self.isIK: + self.target = fs.readBoneIndex() + self.loopCount = fs.readInt() + self.rotationConstraint = fs.readFloat() + + iklink_num = fs.readInt() + self.ik_links = [] + for i in range(iklink_num): + link = IKLink() + link.load(fs) + self.ik_links.append(link) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeVector(self.location) + fs.writeBoneIndex(-1 if self.parent is None else self.parent) + fs.writeInt(self.transform_order) + + flags = 0 + flags |= int(isinstance(self.displayConnection, int)) + flags |= int(self.isRotatable) << 1 + flags |= int(self.isMovable) << 2 + flags |= int(self.visible) << 3 + flags |= int(self.isControllable) << 4 + flags |= int(self.isIK) << 5 + + flags |= int(self.hasAdditionalRotate) << 8 + flags |= int(self.hasAdditionalLocation) << 9 + flags |= int(self.axis is not None) << 10 + flags |= int(self.localCoordinate is not None) << 11 + + flags |= int(self.transAfterPhis) << 12 + flags |= int(self.externalTransKey is not None) << 13 + + fs.writeShort(flags) + + if flags & 0x0001: + fs.writeBoneIndex(self.displayConnection) + else: + fs.writeVector(self.displayConnection) + + if self.hasAdditionalRotate or self.hasAdditionalLocation: + fs.writeBoneIndex(self.additionalTransform[0]) + fs.writeFloat(self.additionalTransform[1]) + + if flags & 0x0400: + fs.writeVector(self.axis) + + if flags & 0x0800: + fs.writeVector(self.localCoordinate.x_axis) + fs.writeVector(self.localCoordinate.z_axis) + + if flags & 0x2000: + fs.writeInt(self.externalTransKey) + + if self.isIK: + fs.writeBoneIndex(self.target) + fs.writeInt(self.loopCount) + fs.writeFloat(self.rotationConstraint) + + fs.writeInt(len(self.ik_links)) + for i in self.ik_links: + i.save(fs) + + +class IKLink: + def __init__(self): + self.target = None + self.maximumAngle = None + self.minimumAngle = None + + def __repr__(self): + return ''%(str(self.target)) + + def load(self, fs): + self.target = fs.readBoneIndex() + flag = fs.readByte() + if flag == 1: + self.minimumAngle = fs.readVector(3) + self.maximumAngle = fs.readVector(3) + else: + self.minimumAngle = None + self.maximumAngle = None + + def save(self, fs): + fs.writeBoneIndex(self.target) + if isinstance(self.minimumAngle, (tuple, list)) and isinstance(self.maximumAngle, (tuple, list)): + fs.writeByte(1) + fs.writeVector(self.minimumAngle) + fs.writeVector(self.maximumAngle) + else: + fs.writeByte(0) + +class Morph: + CATEGORY_SYSTEM = 0 + CATEGORY_EYEBROW = 1 + CATEGORY_EYE = 2 + CATEGORY_MOUTH = 3 + CATEGORY_OHTER = 4 + + def __init__(self, name, name_e, category, **kwargs): + self.offsets = [] + self.name = name + self.name_e = name_e + self.category = category + + def __repr__(self): + return ''%(self.name, self.name_e) + + def type_index(self): + raise NotImplementedError + + @staticmethod + def create(fs): + _CLASSES = { + 0: GroupMorph, + 1: VertexMorph, + 2: BoneMorph, + 3: UVMorph, + 4: UVMorph, + 5: UVMorph, + 6: UVMorph, + 7: UVMorph, + 8: MaterialMorph, + } + + name = fs.readStr() + name_e = fs.readStr() + logging.debug('morph: %s', name) + category = fs.readSignedByte() + typeIndex = fs.readSignedByte() + ret = _CLASSES[typeIndex](name, name_e, category, type_index = typeIndex) + ret.load(fs) + return ret + + def load(self, fs): + """ Implement for loading morph data. + """ + raise NotImplementedError + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + fs.writeSignedByte(self.category) + fs.writeSignedByte(self.type_index()) + fs.writeInt(len(self.offsets)) + for i in self.offsets: + i.save(fs) + +class VertexMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 1 + + def load(self, fs): + num = fs.readInt() + for i in range(num): + t = VertexMorphOffset() + t.load(fs) + self.offsets.append(t) + +class VertexMorphOffset: + def __init__(self): + self.index = 0 + self.offset = [] + + def load(self, fs): + self.index = fs.readVertexIndex() + self.offset = fs.readVector(3) + + def save(self, fs): + fs.writeVertexIndex(self.index) + fs.writeVector(self.offset) + +class UVMorph(Morph): + def __init__(self, *args, **kwargs): + self.uv_index = kwargs.get('type_index', 3) - 3 + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return self.uv_index + 3 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = UVMorphOffset() + t.load(fs) + self.offsets.append(t) + +class UVMorphOffset: + def __init__(self): + self.index = 0 + self.offset = [] + + def load(self, fs): + self.index = fs.readVertexIndex() + self.offset = fs.readVector(4) + + def save(self, fs): + fs.writeVertexIndex(self.index) + fs.writeVector(self.offset) + +class BoneMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 2 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = BoneMorphOffset() + t.load(fs) + self.offsets.append(t) + +class BoneMorphOffset: + def __init__(self): + self.index = None + self.location_offset = [] + self.rotation_offset = [] + + def load(self, fs): + self.index = fs.readBoneIndex() + self.location_offset = fs.readVector(3) + self.rotation_offset = fs.readVector(4) + if not any(self.rotation_offset): + self.rotation_offset = (0, 0, 0, 1) + + def save(self, fs): + fs.writeBoneIndex(self.index) + fs.writeVector(self.location_offset) + fs.writeVector(self.rotation_offset) + +class MaterialMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 8 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = MaterialMorphOffset() + t.load(fs) + self.offsets.append(t) + +class MaterialMorphOffset: + TYPE_MULT = 0 + TYPE_ADD = 1 + + def __init__(self): + self.index = 0 + self.offset_type = 0 + self.diffuse_offset = [] + self.specular_offset = [] + self.shininess_offset = 0 + self.ambient_offset = [] + self.edge_color_offset = [] + self.edge_size_offset = [] + self.texture_factor = [] + self.sphere_texture_factor = [] + self.toon_texture_factor = [] + + def load(self, fs): + self.index = fs.readMaterialIndex() + self.offset_type = fs.readSignedByte() + self.diffuse_offset = fs.readVector(4) + self.specular_offset = fs.readVector(3) + self.shininess_offset = fs.readFloat() + self.ambient_offset = fs.readVector(3) + self.edge_color_offset = fs.readVector(4) + self.edge_size_offset = fs.readFloat() + self.texture_factor = fs.readVector(4) + self.sphere_texture_factor = fs.readVector(4) + self.toon_texture_factor = fs.readVector(4) + + def save(self, fs): + fs.writeMaterialIndex(self.index) + fs.writeSignedByte(self.offset_type) + fs.writeVector(self.diffuse_offset) + fs.writeVector(self.specular_offset) + fs.writeFloat(self.shininess_offset) + fs.writeVector(self.ambient_offset) + fs.writeVector(self.edge_color_offset) + fs.writeFloat(self.edge_size_offset) + fs.writeVector(self.texture_factor) + fs.writeVector(self.sphere_texture_factor) + fs.writeVector(self.toon_texture_factor) + +class GroupMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 0 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = GroupMorphOffset() + t.load(fs) + self.offsets.append(t) + +class GroupMorphOffset: + def __init__(self): + self.morph = None + self.factor = 0.0 + + def load(self, fs): + self.morph = fs.readMorphIndex() + self.factor = fs.readFloat() + + def save(self, fs): + fs.writeMorphIndex(self.morph) + fs.writeFloat(self.factor) + + +class Display: + def __init__(self): + self.name = '' + self.name_e = '' + + self.isSpecial = False + + self.data = [] + + def __repr__(self): + return ''%( + self.name, + self.name_e, + ) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.isSpecial = (fs.readByte() == 1) + num = fs.readInt() + self.data = [] + for i in range(num): + disp_type = fs.readByte() + index = None + if disp_type == 0: + index = fs.readBoneIndex() + elif disp_type == 1: + index = fs.readMorphIndex() + else: + raise Exception('invalid value.') + self.data.append((disp_type, index)) + logging.debug('the number of display elements: %d', len(self.data)) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeByte(int(self.isSpecial)) + fs.writeInt(len(self.data)) + + for disp_type, index in self.data: + fs.writeByte(disp_type) + if disp_type == 0: + fs.writeBoneIndex(index) + elif disp_type == 1: + fs.writeMorphIndex(index) + else: + raise Exception('invalid value.') + +class Rigid: + TYPE_SPHERE = 0 + TYPE_BOX = 1 + TYPE_CAPSULE = 2 + + MODE_STATIC = 0 + MODE_DYNAMIC = 1 + MODE_DYNAMIC_BONE = 2 + def __init__(self): + self.name = '' + self.name_e = '' + + self.bone = None + self.collision_group_number = 0 + self.collision_group_mask = 0 + + self.type = 0 + self.size = [] + + self.location = [] + self.rotation = [] + + self.mass = 1 + self.velocity_attenuation = [] + self.rotation_attenuation = [] + self.bounce = [] + self.friction = [] + + self.mode = 0 + + def __repr__(self): + return ''%( + self.name, + self.name_e, + ) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + boneIndex = fs.readBoneIndex() + if boneIndex != -1: + self.bone = boneIndex + else: + self.bone = None + + self.collision_group_number = fs.readSignedByte() + self.collision_group_mask = fs.readUnsignedShort() + + self.type = fs.readSignedByte() + self.size = fs.readVector(3) + + self.location = fs.readVector(3) + self.rotation = fs.readVector(3) + + self.mass = fs.readFloat() + self.velocity_attenuation = fs.readFloat() + self.rotation_attenuation = fs.readFloat() + self.bounce = fs.readFloat() + self.friction = fs.readFloat() + + self.mode = fs.readSignedByte() + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + if self.bone is None: + fs.writeBoneIndex(-1) + else: + fs.writeBoneIndex(self.bone) + + fs.writeSignedByte(self.collision_group_number) + fs.writeUnsignedShort(self.collision_group_mask) + + fs.writeSignedByte(self.type) + fs.writeVector(self.size) + + fs.writeVector(self.location) + fs.writeVector(self.rotation) + + fs.writeFloat(self.mass) + fs.writeFloat(self.velocity_attenuation) + fs.writeFloat(self.rotation_attenuation) + fs.writeFloat(self.bounce) + fs.writeFloat(self.friction) + + fs.writeSignedByte(self.mode) + +class Joint: + MODE_SPRING6DOF = 0 + def __init__(self): + self.name = '' + self.name_e = '' + + self.mode = 0 + + self.src_rigid = None + self.dest_rigid = None + + self.location = [] + self.rotation = [] + + self.maximum_location = [] + self.minimum_location = [] + self.maximum_rotation = [] + self.minimum_rotation = [] + + self.spring_constant = [] + self.spring_rotation_constant = [] + + def load(self, fs): + try: self._load(fs) + except struct.error: # possibly contains truncated data + if self.src_rigid is None or self.dest_rigid is None: raise + self.location = self.location or (0, 0, 0) + self.rotation = self.rotation or (0, 0, 0) + self.maximum_location = self.maximum_location or (0, 0, 0) + self.minimum_location = self.minimum_location or (0, 0, 0) + self.maximum_rotation = self.maximum_rotation or (0, 0, 0) + self.minimum_rotation = self.minimum_rotation or (0, 0, 0) + self.spring_constant = self.spring_constant or (0, 0, 0) + self.spring_rotation_constant = self.spring_rotation_constant or (0, 0, 0) + + def _load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.mode = fs.readSignedByte() + + self.src_rigid = fs.readRigidIndex() + self.dest_rigid = fs.readRigidIndex() + if self.src_rigid == -1: + self.src_rigid = None + if self.dest_rigid == -1: + self.dest_rigid = None + + self.location = fs.readVector(3) + self.rotation = fs.readVector(3) + + self.minimum_location = fs.readVector(3) + self.maximum_location = fs.readVector(3) + self.minimum_rotation = fs.readVector(3) + self.maximum_rotation = fs.readVector(3) + + self.spring_constant = fs.readVector(3) + self.spring_rotation_constant = fs.readVector(3) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeSignedByte(self.mode) + + if self.src_rigid is not None: + fs.writeRigidIndex(self.src_rigid) + else: + fs.writeRigidIndex(-1) + if self.dest_rigid is not None: + fs.writeRigidIndex(self.dest_rigid) + else: + fs.writeRigidIndex(-1) + + fs.writeVector(self.location) + fs.writeVector(self.rotation) + + fs.writeVector(self.minimum_location) + fs.writeVector(self.maximum_location) + fs.writeVector(self.minimum_rotation) + fs.writeVector(self.maximum_rotation) + + fs.writeVector(self.spring_constant) + fs.writeVector(self.spring_rotation_constant) + + + +def load(path): + with FileReadStream(path) as fs: + logging.info('****************************************') + logging.info(' mmd_tools.pmx module') + logging.info('----------------------------------------') + logging.info(' Start to load model data form a pmx file') + logging.info(' by the mmd_tools.pmx modlue.') + logging.info('') + header = Header() + header.load(fs) + fs.setHeader(header) + model = Model() + try: + model.load(fs) + except struct.error as e: + logging.error(' * Corrupted file: %s', e) + #raise + logging.info(' Finished loading.') + logging.info('----------------------------------------') + logging.info(' mmd_tools.pmx module') + logging.info('****************************************') + return model + +def save(path, model, add_uv_count=0): + with FileWriteStream(path) as fs: + header = Header(model) + header.additional_uvs = max(0, min(4, add_uv_count)) # UV1~UV4 + header.save(fs) + fs.setHeader(header) + model.save(fs) diff --git a/core/importers/pmx/importer.py b/core/mmd/core/pmx/importer.py similarity index 77% rename from core/importers/pmx/importer.py rename to core/mmd/core/pmx/importer.py index 3f0a1f2..d1916a8 100644 --- a/core/importers/pmx/importer.py +++ b/core/mmd/core/pmx/importer.py @@ -1,27 +1,21 @@ # -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. -import bpy import collections +import logging import os import time -import typing -from typing import TYPE_CHECKING, List, Optional, Dict, Set, Tuple, Any, Union +from typing import TYPE_CHECKING, List, Optional + +import bpy from mathutils import Matrix, Vector -from bpy.types import Context, Object - -from ...logging_setup import logger -from ...common import ProgressTracker -from ...translations import t - -from ...mmd.core import bpyutils, utils -from ...mmd.core.bpyutils import FnContext +from ... import bpyutils, utils +from ...bpyutils import FnContext from .. import pmx from ..bone import FnBone from ..material import FnMaterial @@ -32,20 +26,17 @@ from ..vmd.importer import BoneConverter from ...operators.misc import MoveObject if TYPE_CHECKING: - from ...mmd.properties.pose_bone import MMDBone - from ...mmd.properties.root import MMDRoot + from ...properties.pose_bone import MMDBone + from ...properties.root import MMDRoot class PMXImporter: - """PMX model importer for Avatar Toolkit""" - CATEGORIES = { 0: "SYSTEM", 1: "EYEBROW", 2: "EYE", 3: "MOUTH", } - MORPH_TYPES = { 0: "group_morphs", 1: "vertex_morphs", @@ -83,17 +74,15 @@ class PMXImporter: self.__materialFaceCountTable = None @staticmethod - def __safe_name(name: str, max_length: int = 59) -> str: - """Create a safe name that won't exceed Blender's name length limits""" + def __safe_name(name, max_length=59): return str(bytes(name, "utf8")[:max_length], "utf8", errors="replace") @staticmethod - def flipUV_V(uv: Tuple[float, float]) -> Tuple[float, float]: - """Flip the V coordinate of UV mapping""" + def flipUV_V(uv): u, v = uv return u, 1.0 - v - def __createObjects(self) -> None: + def __createObjects(self): """Create main objects and link them to scene.""" pmxModel = self.__model obj_name = self.__safe_name(bpy.path.display_name(pmxModel.filepath), max_length=54) @@ -112,15 +101,13 @@ class PMXImporter: txt.from_string(pmxModel.comment_e.replace("\r", "")) mmd_root.comment_e_text = txt.name - def __createMeshObject(self) -> None: - """Create a mesh object for the model""" + def __createMeshObject(self): model_name = self.__root.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) - def __createBasisShapeKey(self) -> None: - """Create a basis shape key if it doesn't exist""" + def __createBasisShapeKey(self): if self.__meshObj.data.shape_keys: assert len(self.__meshObj.data.vertices) > 0 assert len(self.__meshObj.data.shape_keys.key_blocks) > 1 @@ -128,13 +115,11 @@ class PMXImporter: FnContext.set_active_object(self.__targetContext, self.__meshObj) bpy.ops.object.shape_key_add() - def __importVertexGroup(self) -> None: - """Import vertex groups from bones""" + def __importVertexGroup(self): vgroups = self.__meshObj.vertex_groups self.__vertexGroupTable = [vgroups.new(name=i.name) for i in self.__model.bones] or [vgroups.new(name="NO BONES")] - def __importVertices(self) -> None: - """Import vertices with weights and other properties""" + def __importVertices(self): self.__importVertexGroup() pmxModel = self.__model @@ -180,13 +165,12 @@ 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("Unknown bone weight type.") + raise Exception("unkown bone weight type.") vg_edge_scale.lock_weight = True vg_vertex_order.lock_weight = True - def __storeVerticesSDEF(self) -> None: - """Store SDEF vertex data for smooth deformation""" + def __storeVerticesSDEF(self): if len(self.__sdefVertices) < 1: return @@ -199,28 +183,33 @@ class PMXImporter: sdefC.data[i].co = Vector(w.c).xzy * self.__scale sdefR0.data[i].co = Vector(w.r0).xzy * self.__scale sdefR1.data[i].co = Vector(w.r1).xzy * self.__scale - logger.info("Stored %d SDEF vertices", len(self.__sdefVertices)) + logging.info("Stored %d SDEF vertices", len(self.__sdefVertices)) - def __importTextures(self) -> None: - """Import textures from the PMX model""" + def __importTextures(self): pmxModel = self.__model self.__textureTable = [] for i in pmxModel.textures: self.__textureTable.append(bpy.path.resolve_ncase(path=i.path)) - def __createEditBones(self, obj: Object, pmx_bones: List[Any]) -> Tuple[List[str], List[str]]: - """Create EditBones from pmx file data. + def __createEditBones(self, obj, pmx_bones): + """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) from math import isfinite - def _VectorXZY(v: List[float]) -> Vector: + def _VectorXZY(v): return Vector(v).xzy if all(isfinite(n) for n in v) else Vector((0, 0, 0)) with bpyutils.edit_object(obj) as data: @@ -250,7 +239,7 @@ class PMXImporter: for b_bone, m_bone in zip(editBoneTable, pmx_bones): if m_bone.isIK and m_bone.target != -1: - logger.debug("Checking IK links of %s", b_bone.name) + logging.debug(" - checking IK links of %s", 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] @@ -258,11 +247,11 @@ 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: - logger.warning("Unsolved IK link %s", b_bone_link.name) + logging.warning(" ** unsolved IK link %s **", b_bone_link.name) elif b_bone_tail.parent != b_bone_link: - logger.warning("Skipped IK link %s", b_bone_link.name) + logging.warning(" ** skipped IK link %s **", b_bone_link.name) elif (b_bone_link.tail - b_bone_tail.head).length > 1e-4: - logger.debug("Fix IK link %s", b_bone_link.name) + logging.debug(" * fix IK link %s", b_bone_link.name) b_bone_link.tail = b_bone_link.head + loc for b_bone, m_bone in zip(editBoneTable, pmx_bones): @@ -277,7 +266,7 @@ class PMXImporter: else: b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale if m_bone.displayConnection != -1 and m_bone.displayConnection != [0.0, 0.0, 0.0]: - logger.debug("Special tip bone %s, display %s", b_bone.name, str(m_bone.displayConnection)) + logging.debug(" * special tip bone %s, display %s", b_bone.name, str(m_bone.displayConnection)) specialTipBones.append(b_bone.name) for b_bone, m_bone in zip(editBoneTable, pmx_bones): @@ -297,21 +286,19 @@ class PMXImporter: continue if not m_bone.isMovable: continue - logger.warning("Connected: %s (%d)-> %s", b_bone.name, len(b_bone.children), t.name) + logging.warning(" * connected: %s (%d)-> %s", b_bone.name, len(b_bone.children), t.name) t.use_connect = True return nameTable, specialTipBones - def __sortPoseBonesByBoneIndex(self, pose_bones: List[bpy.types.PoseBone], bone_names: List[str]) -> List[bpy.types.PoseBone]: - """Sort pose bones by their bone index in the PMX file""" + def __sortPoseBonesByBoneIndex(self, pose_bones: List[bpy.types.PoseBone], bone_names): r: List[bpy.types.PoseBone] = [] for i in bone_names: r.append(pose_bones[i]) return r @staticmethod - def convertIKLimitAngles(min_angle: List[float], max_angle: List[float], bone_matrix: Matrix, invert: bool = False) -> Tuple[Vector, Vector]: - """Convert IK limit angles to Blender's coordinate system""" + def convertIKLimitAngles(min_angle, max_angle, bone_matrix, invert=False): mat = bone_matrix.to_3x3() * -1 mat[1], mat[2] = mat[2].copy(), mat[1].copy() mat.transpose() @@ -338,13 +325,25 @@ 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: int, pmx_bone: Any, pose_bones: List[bpy.types.PoseBone]) -> None: - """Create an IK bone constraint + def __applyIk(self, index, pmx_bone, pose_bones): + """create a 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: + # + Root + # | + link1 + # | + link0 (ik_constraint_bone) <- ik constraint, chain_count=2 + # | + IK target (ik_target) <- constraint 'mmd_ik_target_override', subtarget=link0 + # + IK bone (ik_bone) + # + # it is possible that the link0 is the IK target, + # so ik constraint will be on link1, chain_count=1 + # the IK target isn't affected by IK bone + ik_bone = pose_bones[index] ik_target = pose_bones[pmx_bone.target] ik_constraint_bone = ik_target.parent @@ -355,17 +354,16 @@ class PMXImporter: 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] - logger.warning("Fix IK settings of IK bone (%s)", ik_bone.name) + logging.warning(" * fix IK settings of IK bone (%s)", 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 - logger.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) + 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) 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:])): - logger.warning("Invalid IK bone (%s): IK chain does not follow parent-child relationship", ik_bone.name) + logging.warning(" * Invalid IK bone (%s): IK chain does not follow parent-child relationship", ik_bone.name) return if ik_constraint_bone is None or len(pmx_bone.ik_links) < 1: - logger.warning("Invalid IK bone (%s)", ik_bone.name) + logging.warning(" * Invalid IK bone (%s)", ik_bone.name) return c = ik_target.constraints.new(type="DAMPED_TRACK") @@ -421,8 +419,7 @@ 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) -> None: - """Import bones from the PMX model""" + def __importBones(self): pmxModel = self.__model boneNameTable, specialTipBones = self.__createEditBones(self.__armObj, pmxModel.bones) @@ -476,8 +473,7 @@ class PMXImporter: b_bone.lock_location = [True, True, True] b_bone.lock_scale = [True, True, True] - def __importRigids(self) -> None: - """Import rigid bodies from the PMX model""" + def __importRigids(self): start_time = time.time() self.__rigidTable = {} context = FnContext.ensure_context() @@ -509,10 +505,9 @@ class PMXImporter: MoveObject.set_index(obj, i) self.__rigidTable[i] = obj - logger.debug("Finished importing rigid bodies in %.2f seconds", time.time() - start_time) + logging.debug("Finished importing rigid bodies in %f seconds.", time.time() - start_time) - def __importJoints(self) -> None: - """Import joints from the PMX model""" + def __importJoints(self): 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())) @@ -538,10 +533,9 @@ class PMXImporter: obj.hide_set(True) MoveObject.set_index(obj, i) - logger.debug("Finished importing joints in %.2f seconds", time.time() - start_time) + logging.debug("Finished importing joints in %f seconds.", time.time() - start_time) - def __importMaterials(self) -> None: - """Import materials from the PMX model""" + def __importMaterials(self): self.__importTextures() pmxModel = self.__model @@ -594,8 +588,7 @@ class PMXImporter: texture_slot.uv_layer = "UV1" # for SubTexture mmd_mat.sphere_texture_type = str(i.sphere_texture_mode) - def __importFaces(self) -> None: - """Import faces/polygons from the PMX model""" + def __importFaces(self): pmxModel = self.__model mesh = self.__meshObj.data vertex_map = self.__vertex_map @@ -624,42 +617,38 @@ class PMXImporter: bf.image = self.__imageTable.get(mi, None) if pmxModel.header and pmxModel.header.additional_uvs: - logger.info("Importing %d additional uvs", pmxModel.header.additional_uvs) + logging.info("Importing %d additional uvs", pmxModel.header.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] - logger.info(" - %s...(uv channels)", add_uv.name) + logging.info(" - %s...(uv channels)", add_uv.name) 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()): - logger.info("\t- zw are all zeros: %s", add_uv.name) + logging.info("\t- zw are all zeros: %s", 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(): - logger.info(" - %s...(zw channels of %s)", name, name[1:]) + logging.info(" - %s...(zw channels of %s)", name, name[1:]) add_zw = uv_textures.new(name=name) if add_zw is None: - logger.warning("\t* Lost zw channels") + logging.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) - def __fixOverlappingFaceMaterials(self, materials: List[bpy.types.Material], - vertices: List[bpy.types.MeshVertex], - loop_indices: List[int], - material_indices: List[int]) -> None: - """Fix overlapping face materials to prevent z-fighting""" - # FIXME: This is not the best way to setup blend_method, might just work for some common cases. + 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'. # 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 = {} mi_skip = -1 _vi_cache = {} - def _rounded_co_vi(vi: int) -> Tuple[float, float, float]: + def _rounded_co_vi(vi): 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)) @@ -674,13 +663,12 @@ class PMXImporter: if verts not in check: check[verts] = mi elif check[verts] < mi: - logger.debug("Fix blend method of material: %s", materials[mi].name) + logging.debug(" >> fix blend method of material: %s", materials[mi].name) materials[mi].blend_method = "BLEND" materials[mi].show_transparent_back = False mi_skip = mi - def __importVertexMorphs(self) -> None: - """Import vertex morphs from the PMX model""" + def __importVertexMorphs(self): mmd_root = self.__root.mmd_root categories = self.CATEGORIES self.__createBasisShapeKey() @@ -694,8 +682,7 @@ class PMXImporter: shapeKeyPoint = shapeKey.data[md.index] shapeKeyPoint.co += Vector(md.offset).xzy * self.__scale - def __importMaterialMorphs(self) -> None: - """Import material morphs from the PMX model""" + def __importMaterialMorphs(self): mmd_root = self.__root.mmd_root categories = self.CATEGORIES for morph in (x for x in self.__model.morphs if isinstance(x, pmx.MaterialMorph)): @@ -719,8 +706,7 @@ class PMXImporter: data.sphere_texture_factor = morph_data.sphere_texture_factor data.toon_texture_factor = morph_data.toon_texture_factor - def __importBoneMorphs(self) -> None: - """Import bone morphs from the PMX model""" + def __importBoneMorphs(self): mmd_root = self.__root.mmd_root categories = self.CATEGORIES for morph in (x for x in self.__model.morphs if isinstance(x, pmx.BoneMorph)): @@ -738,8 +724,7 @@ class PMXImporter: data.location = converter.convert_location(morph_data.location_offset) data.rotation = converter.convert_rotation(morph_data.rotation_offset) - def __importUVMorphs(self) -> None: - """Import UV morphs from the PMX model""" + def __importUVMorphs(self): mmd_root = self.__root.mmd_root categories = self.CATEGORIES __OffsetData = collections.namedtuple("OffsetData", "index, offset") @@ -755,8 +740,7 @@ class PMXImporter: FnMorph.store_uv_morph_data(self.__meshObj, uv_morph, offsets, "") uv_morph.data_type = "VERTEX_GROUP" - def __importGroupMorphs(self) -> None: - """Import group morphs from the PMX model""" + def __importGroupMorphs(self): mmd_root = self.__root.mmd_root categories = self.CATEGORIES morph_types = self.MORPH_TYPES @@ -775,8 +759,7 @@ class PMXImporter: data.morph_type = morph_types[m.type_index()] data.factor = morph_data.factor - def __importDisplayFrames(self) -> None: - """Import display frames from the PMX model""" + def __importDisplayFrames(self): pmxModel = self.__model root = self.__root morph_types = self.MORPH_TYPES @@ -801,18 +784,17 @@ class PMXImporter: FnBone.sync_bone_collections_from_display_item_frames(self.__armObj) - def __addArmatureModifier(self, meshObj: Object, armObj: Object) -> None: - """Add armature modifier to mesh object""" + def __addArmatureModifier(self, meshObj, armObj): + # TODO: move to model.py 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 - def __assignCustomNormals(self) -> None: - """Assign custom normals to the mesh""" + def __assignCustomNormals(self): mesh: bpy.types.Mesh = self.__meshObj.data - logger.info("Setting custom normals...") + logging.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] @@ -820,29 +802,26 @@ class PMXImporter: else: custom_normals = [(Vector(v.normal).xzy).normalized() for v in self.__model.vertices] mesh.normals_split_custom_set_from_vertices(custom_normals) - logger.info("Custom normals applied successfully") + logging.info(" - Done!!") - def __renameLRBones(self, use_underscore: bool) -> None: - """Rename left/right bones with proper naming convention""" + def __renameLRBones(self, use_underscore): 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 - def __translateBoneNames(self) -> None: - """Translate bone names using the provided translator""" + def __translateBoneNames(self): pose_bones = self.__armObj.pose.bones for i in pose_bones: self.__rig.renameBone(i.name, self.__translator.translate(i.name)) - def __fixRepeatedMorphName(self) -> None: - """Fix repeated morph names to ensure uniqueness""" + def __fixRepeatedMorphName(self): used_names = set() for m in self.__model.morphs: m.name = utils.unique_name(m.name or "Morph", used_names) used_names.add(m.name) - def execute(self, context: Context, **args) -> None: - """Execute the PMX import process with the given arguments""" + def execute(self, **args): if "pmx" in args: self.__model = args["pmx"] else: @@ -860,95 +839,78 @@ class PMXImporter: self.__apply_bone_fixed_axis = args.get("apply_bone_fixed_axis", False) self.__translator = args.get("translator", None) - logger.info("****************************************") - logger.info("Starting PMX import process") - logger.info("----------------------------------------") + 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() - with ProgressTracker(context, 100, "Importing PMX Model") as progress: - self.__createObjects() - progress.step("Created base objects") + self.__createObjects() - if "MESH" in types: - if clean_model: - _PMXCleaner.clean(self.__model, "MORPHS" not in types) - if remove_doubles: - self.__vertex_map = _PMXCleaner.remove_doubles(self.__model, "MORPHS" not in types) - - progress.step("Preparing mesh data") + if "MESH" in types: + if clean_model: + _PMXCleaner.clean(self.__model, "MORPHS" not in types) + if remove_doubles: + self.__vertex_map = _PMXCleaner.remove_doubles(self.__model, "MORPHS" not in types) + self.__createMeshObject() + self.__importVertices() + self.__importMaterials() + self.__importFaces() + self.__meshObj.data.update() + self.__assignCustomNormals() + self.__storeVerticesSDEF() + + if "ARMATURE" in types: + # for tracking bone order + if "MESH" not in types: self.__createMeshObject() - progress.step("Importing vertices") - self.__importVertices() - progress.step("Importing materials") - self.__importMaterials() - progress.step("Importing faces") - self.__importFaces() - self.__meshObj.data.update() - progress.step("Assigning custom normals") - self.__assignCustomNormals() - progress.step("Processing SDEF vertices") - self.__storeVerticesSDEF() + self.__importVertexGroup() + self.__importBones() + if args.get("rename_LR_bones", False): + use_underscore = args.get("use_underscore", False) + self.__renameLRBones(use_underscore) + if self.__translator: + self.__translateBoneNames() + if self.__apply_bone_fixed_axis: + FnBone.apply_bone_fixed_axis(self.__armObj) + FnBone.apply_additional_transformation(self.__armObj) - if "ARMATURE" in types: - progress.step("Preparing armature") - # for tracking bone order - if "MESH" not in types: - self.__createMeshObject() - self.__importVertexGroup() - progress.step("Importing bones") - self.__importBones() - if args.get("rename_LR_bones", False): - use_underscore = args.get("use_underscore", False) - self.__renameLRBones(use_underscore) - if self.__translator: - self.__translateBoneNames() - if self.__apply_bone_fixed_axis: - FnBone.apply_bone_fixed_axis(self.__armObj) - FnBone.apply_additional_transformation(self.__armObj) + if "PHYSICS" in types: + self.__importRigids() + self.__importJoints() - if "PHYSICS" in types: - progress.step("Importing rigid bodies") - self.__importRigids() - progress.step("Importing joints") - self.__importJoints() + if "DISPLAY" in types: + self.__importDisplayFrames() + else: + self.__rig.initialDisplayFrames() - if "DISPLAY" in types: - progress.step("Importing display frames") - self.__importDisplayFrames() - else: - self.__rig.initialDisplayFrames() + if "MORPHS" in types: + self.__importGroupMorphs() + self.__importVertexMorphs() + self.__importBoneMorphs() + self.__importMaterialMorphs() + self.__importUVMorphs() - if "MORPHS" in types: - progress.step("Importing group morphs") - self.__importGroupMorphs() - progress.step("Importing vertex morphs") - self.__importVertexMorphs() - progress.step("Importing bone morphs") - self.__importBoneMorphs() - progress.step("Importing material morphs") - self.__importMaterialMorphs() - progress.step("Importing UV morphs") - self.__importUVMorphs() + if self.__meshObj: + self.__addArmatureModifier(self.__meshObj, self.__armObj) - if self.__meshObj: - progress.step("Adding armature modifier") - self.__addArmatureModifier(self.__meshObj, self.__armObj) + 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) - FnModel.change_mmd_ik_loop_factor(self.__root, args.get("ik_loop_factor", 1)) - utils.selectAObject(self.__root) - - logger.info("Finished importing the model in %.2f seconds", time.time() - start_time) - logger.info("----------------------------------------") + logging.info(" Finished importing the model in %f seconds.", time.time() - start_time) + logging.info("----------------------------------------") + logging.info(" mmd_tools.import_pmx module") + logging.info("****************************************") class _PMXCleaner: - """Helper class for cleaning PMX data during import""" - @classmethod - def clean(cls, pmx_model: Any, mesh_only: bool) -> None: - """Clean PMX data by removing unused vertices and faces""" - logger.info("Cleaning PMX data...") + def clean(cls, pmx_model, mesh_only): + logging.info("Cleaning PMX data...") pmx_faces = pmx_model.faces pmx_vertices = pmx_model.vertices @@ -958,7 +920,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: - logger.info("Vertices are clean, no cleaning needed") + logging.info(" (vertices is clean)") else: new_vertex_count = 0 for v in sorted(index_map): @@ -966,7 +928,7 @@ class _PMXCleaner: pmx_vertices[new_vertex_count] = pmx_vertices[v] index_map[v] = new_vertex_count new_vertex_count += 1 - logger.warning("Removed %d unused vertices", len(pmx_vertices) - new_vertex_count) + logging.warning(" - removed %d vertices", len(pmx_vertices) - new_vertex_count) del pmx_vertices[new_vertex_count:] # update vertex indices of faces @@ -974,7 +936,7 @@ class _PMXCleaner: f[:] = [index_map[v] for v in f] if mesh_only: - logger.info("Mesh-only cleaning completed") + logging.info(" - Done (mesh only)!!") return if not is_index_clean: @@ -984,12 +946,11 @@ class _PMXCleaner: return x.index is not None cls.__clean_pmx_morphs(pmx_model.morphs, __update_index) - logger.info("PMX cleaning completed") + logging.info(" - Done!!") @classmethod - def remove_doubles(cls, pmx_model: Any, mesh_only: bool) -> Optional[Dict[int, Tuple[int, int]]]: - """Remove duplicate vertices from the PMX model""" - logger.info("Removing duplicate vertices...") + def remove_doubles(cls, pmx_model, mesh_only): + logging.info("Removing doubles...") pmx_vertices = pmx_model.vertices vertex_map = [None] * len(pmx_vertices) @@ -1013,17 +974,18 @@ class _PMXCleaner: counts = len(vertex_map) - len(keys) keys.clear() if counts: - logger.warning("%d duplicate vertices will be removed", counts) + logging.warning(" - %d vertices will be removed", counts) else: - logger.info("No duplicate vertices found") + logging.info(" - Done (no changes)!!") return None # clean face + # face_key_func = lambda f: frozenset(vertex_map[x][0] for x in f) face_key_func = lambda f: frozenset({vertex_map[x][0]: tuple(pmx_vertices[x].uv) for x in f}.items()) cls.__clean_pmx_faces(pmx_model.faces, pmx_model.materials, face_key_func) if mesh_only: - logger.info("Mesh-only duplicate removal completed") + logging.info(" - Done (mesh only)!!") else: # clean vertex/uv morphs def __update_index(x): @@ -1032,12 +994,11 @@ class _PMXCleaner: return x.index is not None cls.__clean_pmx_morphs(pmx_model.morphs, __update_index) - logger.info("Duplicate removal completed") + logging.info(" - Done!!") return vertex_map @staticmethod - def __clean_pmx_faces(pmx_faces: List[List[int]], pmx_materials: List[Any], face_key_func: Callable) -> None: - """Clean PMX faces by removing duplicates and updating material vertex counts""" + def __clean_pmx_faces(pmx_faces, pmx_materials, face_key_func): new_face_count = 0 face_iter = iter(pmx_faces) for mat in pmx_materials: @@ -1057,14 +1018,13 @@ class _PMXCleaner: mat.vertex_count = new_vertex_count face_iter = None if new_face_count == len(pmx_faces): - logger.info("Faces are clean, no cleaning needed") + logging.info(" (faces is clean)") else: - logger.warning("Removed %d duplicate faces", len(pmx_faces) - new_face_count) + logging.warning(" - removed %d faces", len(pmx_faces) - new_face_count) del pmx_faces[new_face_count:] @staticmethod - def __clean_pmx_morphs(pmx_morphs: List[Any], index_update_func: Callable) -> None: - """Clean PMX morphs by updating vertex indices and removing invalid offsets""" + def __clean_pmx_morphs(pmx_morphs, index_update_func): for m in pmx_morphs: if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): continue @@ -1072,4 +1032,4 @@ class _PMXCleaner: m.offsets = [x for x in m.offsets if index_update_func(x)] counts = old_len - len(m.offsets) if counts: - logger.warning('Removed %d (of %d) offsets from morph "%s"', counts, old_len, m.name) + logging.warning(' - removed %d (of %d) offsets of "%s"', counts, old_len, m.name) diff --git a/core/mmd/core/rigid_body.py b/core/mmd/core/rigid_body.py new file mode 100644 index 0000000..ec3aeb8 --- /dev/null +++ b/core/mmd/core/rigid_body.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import List, Optional + +import bpy +from mathutils import Euler, Vector + +from ..bpyutils import FnContext, Props + +SHAPE_SPHERE = 0 +SHAPE_BOX = 1 +SHAPE_CAPSULE = 2 + +MODE_STATIC = 0 +MODE_DYNAMIC = 1 +MODE_DYNAMIC_BONE = 2 + + +def shapeType(collision_shape): + return ("SPHERE", "BOX", "CAPSULE").index(collision_shape) + + +def collisionShape(shape_type): + return ("SPHERE", "BOX", "CAPSULE")[shape_type] + + +def setRigidBodyWorldEnabled(enable): + if bpy.ops.rigidbody.world_add.poll(): + bpy.ops.rigidbody.world_add() + rigidbody_world = bpy.context.scene.rigidbody_world + enabled = rigidbody_world.enabled + rigidbody_world.enabled = enable + return enabled + + +class RigidBodyMaterial: + COLORS = [ + 0x7FDDD4, + 0xF0E68C, + 0xEE82EE, + 0xFFE4E1, + 0x8FEEEE, + 0xADFF2F, + 0xFA8072, + 0x9370DB, + 0x40E0D0, + 0x96514D, + 0x5A964E, + 0xE6BFAB, + 0xD3381C, + 0x165E83, + 0x701682, + 0x828216, + ] + + @classmethod + def getMaterial(cls, number): + number = int(number) + material_name = "mmd_tools_rigid_%d" % (number) + if material_name not in bpy.data.materials: + 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)] + mat.specular_intensity = 0 + if len(mat.diffuse_color) > 3: + mat.diffuse_color[3] = 0.5 + mat.blend_method = "BLEND" + if hasattr(mat, "shadow_method"): + mat.shadow_method = "NONE" + mat.use_backface_culling = True + mat.show_transparent_back = False + mat.use_nodes = True + nodes, links = mat.node_tree.nodes, mat.node_tree.links + nodes.clear() + node_color = nodes.new("ShaderNodeBackground") + node_color.inputs["Color"].default_value = mat.diffuse_color + node_output = nodes.new("ShaderNodeOutputMaterial") + links.new(node_color.outputs[0], node_output.inputs["Surface"]) + else: + mat = bpy.data.materials[material_name] + return mat + + +class FnRigidBody: + @staticmethod + def new_rigid_body_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int) -> List[bpy.types.Object]: + if count < 1: + return [] + + obj = FnRigidBody.new_rigid_body_object(context, parent_object) + + if count == 1: + return [obj] + + return FnContext.duplicate_object(context, obj, count) + + @staticmethod + def new_rigid_body_object(context: bpy.types.Context, parent_object: bpy.types.Object) -> bpy.types.Object: + 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" + obj.rotation_mode = "YXZ" + setattr(obj, Props.display_type, "SOLID") + obj.show_transparent = True + obj.hide_render = True + obj.display.show_shadows = False + + with context.temp_override(object=obj): + bpy.ops.rigidbody.object_add(type="ACTIVE") + + return obj + + @staticmethod + def setup_rigid_body_object( + obj: bpy.types.Object, + shape_type: str, + location: Vector, + rotation: Euler, + size: Vector, + dynamics_type: str, + collision_group_number: Optional[int] = None, + collision_group_mask: Optional[List[bool]] = None, + name: Optional[str] = None, + name_e: Optional[str] = None, + bone: Optional[str] = None, + friction: Optional[float] = None, + mass: Optional[float] = None, + angular_damping: Optional[float] = None, + linear_damping: Optional[float] = None, + bounce: Optional[float] = None, + ) -> bpy.types.Object: + obj.location = location + obj.rotation_euler = rotation + + obj.mmd_rigid.shape = collisionShape(shape_type) + obj.mmd_rigid.size = size + obj.mmd_rigid.type = str(dynamics_type) if dynamics_type in range(3) else "1" + + if collision_group_number is not None: + obj.mmd_rigid.collision_group_number = collision_group_number + + if collision_group_mask is not None: + obj.mmd_rigid.collision_group_mask = collision_group_mask + + if name is not None: + obj.name = name + obj.mmd_rigid.name_j = name + obj.data.name = name + + if name_e is not None: + obj.mmd_rigid.name_e = name_e + + if bone is not None: + obj.mmd_rigid.bone = bone + else: + obj.mmd_rigid.bone = "" + + rb = obj.rigid_body + if friction is not None: + rb.friction = friction + if mass is not None: + rb.mass = mass + if angular_damping is not None: + rb.angular_damping = angular_damping + if linear_damping is not None: + rb.linear_damping = linear_damping + if bounce is not None: + rb.restitution = bounce + + return obj + + @staticmethod + def get_rigid_body_size(obj: bpy.types.Object): + assert obj.mmd_type == "RIGID_BODY" + + x0, y0, z0 = obj.bound_box[0] + x1, y1, z1 = obj.bound_box[6] + assert x1 >= x0 and y1 >= y0 and z1 >= z0 + + shape = obj.mmd_rigid.shape + if shape == "SPHERE": + radius = (z1 - z0) / 2 + return (radius, 0.0, 0.0) + elif shape == "BOX": + x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2 + return (x, y, z) + elif shape == "CAPSULE": + diameter = x1 - x0 + radius = diameter / 2 + height = abs((z1 - z0) - diameter) + return (radius, height, 0.0) + else: + raise ValueError(f"Invalid shape type: {shape}") + + @staticmethod + def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object: + obj = FnContext.new_and_link_object(context, name="Joint", object_data=None) + obj.parent = parent_object + obj.mmd_type = "JOINT" + obj.rotation_mode = "YXZ" + setattr(obj, Props.empty_display_type, "ARROWS") + setattr(obj, Props.empty_display_size, 0.1 * empty_display_size) + obj.hide_render = True + + with context.temp_override(): + context.view_layer.objects.active = obj + bpy.ops.rigidbody.constraint_add(type="GENERIC_SPRING") + + rigid_body_constraint = obj.rigid_body_constraint + rigid_body_constraint.disable_collisions = False + rigid_body_constraint.use_limit_ang_x = True + rigid_body_constraint.use_limit_ang_y = True + rigid_body_constraint.use_limit_ang_z = True + rigid_body_constraint.use_limit_lin_x = True + rigid_body_constraint.use_limit_lin_y = True + rigid_body_constraint.use_limit_lin_z = True + rigid_body_constraint.use_spring_x = True + rigid_body_constraint.use_spring_y = True + rigid_body_constraint.use_spring_z = True + rigid_body_constraint.use_spring_ang_x = True + rigid_body_constraint.use_spring_ang_y = True + rigid_body_constraint.use_spring_ang_z = True + + return obj + + @staticmethod + def new_joint_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int, empty_display_size: float) -> List[bpy.types.Object]: + if count < 1: + return [] + + obj = FnRigidBody.new_joint_object(context, parent_object, empty_display_size) + + if count == 1: + return [obj] + + return FnContext.duplicate_object(context, obj, count) + + @staticmethod + def setup_joint_object( + obj: bpy.types.Object, + location: Vector, + rotation: Euler, + rigid_a: bpy.types.Object, + rigid_b: bpy.types.Object, + maximum_location: Vector, + minimum_location: Vector, + maximum_rotation: Euler, + minimum_rotation: Euler, + spring_angular: Vector, + spring_linear: Vector, + name: str, + name_e: Optional[str] = None, + ) -> bpy.types.Object: + obj.name = f"J.{name}" + + obj.location = location + obj.rotation_euler = rotation + + rigid_body_constraint = obj.rigid_body_constraint + rigid_body_constraint.object1 = rigid_a + rigid_body_constraint.object2 = rigid_b + rigid_body_constraint.limit_lin_x_upper = maximum_location.x + rigid_body_constraint.limit_lin_y_upper = maximum_location.y + rigid_body_constraint.limit_lin_z_upper = maximum_location.z + + rigid_body_constraint.limit_lin_x_lower = minimum_location.x + rigid_body_constraint.limit_lin_y_lower = minimum_location.y + rigid_body_constraint.limit_lin_z_lower = minimum_location.z + + rigid_body_constraint.limit_ang_x_upper = maximum_rotation.x + rigid_body_constraint.limit_ang_y_upper = maximum_rotation.y + rigid_body_constraint.limit_ang_z_upper = maximum_rotation.z + + rigid_body_constraint.limit_ang_x_lower = minimum_rotation.x + rigid_body_constraint.limit_ang_y_lower = minimum_rotation.y + rigid_body_constraint.limit_ang_z_lower = minimum_rotation.z + + obj.mmd_joint.name_j = name + if name_e is not None: + obj.mmd_joint.name_e = name_e + + obj.mmd_joint.spring_linear = spring_linear + obj.mmd_joint.spring_angular = spring_angular + + return obj diff --git a/core/mmd/core/sdef.py b/core/mmd/core/sdef.py new file mode 100644 index 0000000..4e4f768 --- /dev/null +++ b/core/mmd/core/sdef.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import time + +import bpy +from mathutils import Matrix, Vector + +from ..bpyutils import FnObject + + +def _hash(v): + if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)): + return hash(type(v).__name__ + v.name) + elif isinstance(v, bpy.types.Pose): + return hash(type(v).__name__ + v.id_data.name) + else: + raise NotImplementedError("hash") + + +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" + + def __init__(self): + raise NotImplementedError("not allowed") + + @classmethod + def __init_cache(cls, obj, shapekey): + 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: + cls.g_verts[key] = cls.__find_vertices(obj) + cls.g_bone_check[key] = {} + cls.__g_armature_check[key] = key_armature + cls.g_shapekey_data[key] = None + return True + return False + + @classmethod + def __check_bone_update(cls, obj, bone0, bone1): + check = cls.g_bone_check[_hash(obj)] + key = (_hash(bone0), _hash(bone1)) + if key not in check or (bone0.matrix, bone1.matrix) != check[key]: + check[key] = (bone0.matrix.copy(), bone1.matrix.copy()) + return True + return False + + @classmethod + def mute_sdef_set(cls, obj, mute): + key_blocks = getattr(obj.data.shape_keys, "key_blocks", ()) + if cls.SHAPEKEY_NAME in key_blocks: + shapekey = key_blocks[cls.SHAPEKEY_NAME] + shapekey.mute = mute + if cls.has_sdef_data(obj): + cls.__init_cache(obj, shapekey) + cls.__sdef_muted(obj, shapekey) + + @classmethod + def __sdef_muted(cls, obj, shapekey): + mute = shapekey.mute + if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"): + mod = obj.modifiers.get("mmd_bone_order_override") + if mod and mod.type == "ARMATURE": + if not mute and cls.MASK_NAME not in obj.vertex_groups and obj.mode != "EDIT": + mask = tuple(i for v in cls.g_verts[_hash(obj)].values() for i in v[3]) + obj.vertex_groups.new(name=cls.MASK_NAME).add(mask, 1, "REPLACE") + mod.vertex_group = "" if mute else cls.MASK_NAME + mod.invert_vertex_group = True + shapekey.vertex_group = cls.MASK_NAME + cls.g_bone_check[_hash(obj)]["sdef_mute"] = mute + return mute + + @staticmethod + def has_sdef_data(obj): + 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) + return kb and "mmd_sdef_c" in kb and "mmd_sdef_r0" in kb and "mmd_sdef_r1" in kb + return False + + @classmethod + def __find_vertices(cls, obj): + if not cls.has_sdef_data(obj): + return {} + + vertices = {} + 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} + 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 + + 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 + if len(bgs) >= 2: + bgs.sort(key=lambda x: x.group) + # preprocessing + w0, w1 = bgs[0].weight, bgs[1].weight + # w0 + w1 == 1 + w0 = w0 / (w0 + w1) + w1 = 1 - w0 + + c, r0, r1 = sdef_c[i].co, sdef_r0[i].co, sdef_r1[i].co + rw = r0 * w0 + r1 * w1 + r0 = c + r0 - rw + r1 = c + r1 - rw + + key = (bgs[0].group, bgs[1].group) + if key not in vertices: + # TODO basically we can not cache any bone reference + 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) + return vertices + + @classmethod + def driver_function_wrap(cls, obj_name, bulk_update, use_skip, use_scale): + 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): + 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 + # cls.driver_function(shapekey.id_data.original.key_blocks[shapekey.name], obj_name, bulk_update, use_skip, use_scale) # update original data + data_path = shapekey.path_from_id("value") + obj = next(i for i in shapekey.id_data.animation_data.drivers if i.data_path == data_path).driver.variables["obj"].targets[0].id + cls.__init_cache(obj, shapekey) + if cls.__sdef_muted(obj, shapekey): + return 0.0 + + pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones + if not bulk_update: + shapekey_data = shapekey.data + if use_scale: + # with scale + key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME) + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + # if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + # continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + s0, s1 = mat0.to_scale(), mat1.to_scale() + for vid, w0, w1, pos_c, cr0, cr1 in sdef_data: + s = s0 * w0 + s1 * w1 + mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix() @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])]) + delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = '' + shapekey_data[vid].co = (mat_rot @ (pos_c + delta)) - delta + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 + else: + # default + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + # workaround some weird result of matrix.to_quaternion() using to_euler(), but still minor issues + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + for vid, w0, w1, pos_c, cr0, cr1 in sdef_data: + mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix() + shapekey_data[vid].co = (mat_rot @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 + else: # bulk update + shapekey_data = cls.g_shapekey_data[_hash(obj)] + if shapekey_data is None: + import numpy as np + + shapekey_data = np.zeros(len(shapekey.data) * 3, dtype=np.float32) + shapekey.data.foreach_get("co", shapekey_data) + shapekey_data = cls.g_shapekey_data[_hash(obj)] = shapekey_data.reshape(len(shapekey.data), 3) + if use_scale: + # scale & bulk update + key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME) + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + # if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + # continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + s0, s1 = mat0.to_scale(), mat1.to_scale() + + def scale(mat_rot, w0, w1): + 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): + delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = '' + return (mat_rot @ (pos_c + delta)) - delta + + shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] + else: + # bulk update + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + shapekey_data[vids] = [((rot0 * w0 + rot1 * w1).normalized().to_matrix() @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] + shapekey.data.foreach_set("co", shapekey_data.reshape(3 * len(shapekey.data))) + + return 1.0 # shapkey value + + @classmethod + def register_driver_function(cls): + if "mmd_sdef_driver" not in bpy.app.driver_namespace: + bpy.app.driver_namespace["mmd_sdef_driver"] = cls.driver_function + if "mmd_sdef_driver_wrap" not in bpy.app.driver_namespace: + bpy.app.driver_namespace["mmd_sdef_driver_wrap"] = cls.driver_function_wrap + + BENCH_LOOP = 10 + + @classmethod + def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip): + # warmed up + cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale) + cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale) + # benchmark + t = time.time() + for i in range(cls.BENCH_LOOP): + cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale) + default_time = time.time() - t + t = time.time() + for i in range(cls.BENCH_LOOP): + 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) + return result + + @classmethod + def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False): + # Unbind first + cls.unbind(obj) + if not cls.has_sdef_data(obj): + return False + # Create the shapekey for the driver + shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False) + cls.__init_cache(obj, shapekey) + cls.__sdef_muted(obj, shapekey) + cls.register_driver_function() + if bulk_update is None: + bulk_update = cls.__get_benchmark_result(obj, shapekey, use_scale, use_skip) + # Add the driver to the shapekey + f = obj.data.shape_keys.driver_add('key_blocks["' + cls.SHAPEKEY_NAME + '"].value', -1) + if hasattr(f.driver, "show_debug_info"): + f.driver.show_debug_info = False + f.driver.type = "SCRIPTED" + ov = f.driver.variables.new() + ov.name = "obj" + ov.type = "SINGLE_PROP" + ov.targets[0].id = obj + ov.targets[0].data_path = "name" + if not bulk_update and use_skip: # FIXME: force disable use_skip=True for bulk_update=False on 2.8 + use_skip = False + mod = obj.modifiers.get("mmd_bone_order_override") + variables = f.driver.variables + for name in set(data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)): # add required bones for dependency graph + var = variables.new() + var.type = "TRANSFORMS" + var.targets[0].id = mod.object + var.targets[0].bone_target = name + f.driver.use_self = True + param = (bulk_update, use_skip, use_scale) + f.driver.expression = "mmd_sdef_driver(self, obj, bulk_update={}, use_skip={}, use_scale={})".format(*param) + return True + + @classmethod + def unbind(cls, obj): + if obj.data.shape_keys: + if cls.SHAPEKEY_NAME in obj.data.shape_keys.key_blocks: + 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: + mod.vertex_group = "" + mod.invert_vertex_group = False + break + if cls.MASK_NAME in obj.vertex_groups: + obj.vertex_groups.remove(obj.vertex_groups[cls.MASK_NAME]) + cls.clear_cache(obj) + + @classmethod + def clear_cache(cls, obj=None, unused_only=False): + if unused_only: + valid_keys = set(_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj) + for key in cls.g_verts.keys() - valid_keys: + del cls.g_verts[key] + for key in cls.g_shapekey_data.keys() - cls.g_verts.keys(): + del cls.g_shapekey_data[key] + for key in cls.g_bone_check.keys() - cls.g_verts.keys(): + del cls.g_bone_check[key] + elif obj: + key = _hash(obj) + if key in cls.g_verts: + del cls.g_verts[key] + if key in cls.g_shapekey_data: + del cls.g_shapekey_data[key] + if key in cls.g_bone_check: + del cls.g_bone_check[key] + else: + 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 new file mode 100644 index 0000000..9d32742 --- /dev/null +++ b/core/mmd/core/shader.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Optional, Tuple, cast +import bpy + + +class _NodeTreeUtils: + def __init__(self, shader: bpy.types.ShaderNodeTree): + self.shader = shader + self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore + self.links = shader.links + + def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]: + return next((n for n in self.nodes if n.bl_idname == node_type), None) + + def new_node(self, idname: str, pos: Tuple[int, int]) -> bpy.types.ShaderNode: + node: bpy.types.ShaderNode = self.nodes.new(idname) + node.location = (pos[0] * 210, pos[1] * 220) + return node + + def new_math_node(self, operation, pos, value1=None, value2=None): + node = self.new_node("ShaderNodeMath", pos) + node.operation = operation + if value1 is not None: + node.inputs[0].default_value = value1 + if value2 is not None: + node.inputs[1].default_value = value2 + return node + + def new_vector_math_node(self, operation, pos, vector1=None, vector2=None): + node = self.new_node("ShaderNodeVectorMath", pos) + node.operation = operation + if vector1 is not None: + node.inputs[0].default_value = vector1 + if vector2 is not None: + node.inputs[1].default_value = vector2 + return node + + def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None): + node = self.new_node("ShaderNodeMixRGB", pos) + node.blend_type = blend_type + if fac is not None: + node.inputs["Fac"].default_value = fac + if color1 is not None: + node.inputs["Color1"].default_value = color1 + if color2 is not None: + node.inputs["Color2"].default_value = color2 + return node + + +SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"} + +SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"} + + +class _NodeGroupUtils(_NodeTreeUtils): + def __init__(self, shader: bpy.types.ShaderNodeTree): + super().__init__(shader) + self.__node_input: Optional[bpy.types.NodeGroupInput] = None + self.__node_output: Optional[bpy.types.NodeGroupOutput] = None + + @property + def node_input(self) -> bpy.types.NodeGroupInput: + if not self.__node_input: + self.__node_input = cast(bpy.types.NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) + return self.__node_input + + @property + def node_output(self) -> bpy.types.NodeGroupOutput: + if not self.__node_output: + self.__node_output = cast(bpy.types.NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) + return self.__node_output + + def hide_nodes(self, hide_sockets=True): + skip_nodes = {self.__node_input, self.__node_output} + for n in (x for x in self.nodes if x not in skip_nodes): + n.hide = True + if not hide_sockets: + continue + for s in n.inputs: + s.hide = not s.is_linked + 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): + 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): + 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): + if io_name not in io_sockets: + idname = socket_type or socket.bl_idname + interface_socket = self.shader.interface.new_socket(name=io_name, in_out=in_out, socket_type=SOCKET_TYPE_MAPPING.get(idname, idname)) + if idname in SOCKET_SUBTYPE_MAPPING: + interface_socket.subtype = SOCKET_SUBTYPE_MAPPING.get(idname, "") + if not min_max: + if idname.endswith("Factor") or io_name.endswith("Alpha"): + interface_socket.min_value, interface_socket.max_value = 0, 1 + elif idname.endswith("Float") or idname.endswith("Vector"): + interface_socket.min_value, interface_socket.max_value = -10, 10 + if socket is not None: + self.links.new(io_sockets[io_name], socket) + if default_val is not None: + interface_socket.default_value = default_val + if min_max is not None: + interface_socket.min_value, interface_socket.max_value = min_max + + +class _MaterialMorph: + @classmethod + def update_morph_inputs(cls, material, morph): + if material and material.node_tree and morph.name in material.node_tree.nodes: + 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): + node, nodes = None, [] + for m in morphs: + node = cls.__morph_node_add(material, m, node) + nodes.append(node) + if node: + node = cls.__morph_node_add(material, None, node) or node + for n in reversed(nodes): + n.location += node.location + if n.node_tree.name != node.node_tree.name: + n.location.x -= 100 + if node.name.startswith("mmd_"): + n.location.y += 1500 + node = n + return nodes + + @classmethod + def reset_morph_links(cls, node): + cls.__update_morph_links(node, reset=True) + + @classmethod + def __update_morph_links(cls, node, reset=False): + nodes, links = node.id_data.nodes, node.id_data.links + if reset: + if any(l.from_node.name.startswith("mmd_bind") for i in node.inputs for l in i.links): + return + + def __init_link(socket_morph, socket_shader): + if socket_shader and socket_morph.is_linked: + links.new(socket_morph.links[0].from_socket, socket_shader) + + else: + + def __init_link(socket_morph, socket_shader): + if socket_shader: + if socket_shader.is_linked: + links.new(socket_shader.links[0].from_socket, socket_morph) + if socket_morph.type == "VALUE": + socket_morph.default_value = socket_shader.default_value + else: + socket_morph.default_value[:3] = socket_shader.default_value[:3] + + shader = nodes.get("mmd_shader", None) + if shader: + __init_link(node.inputs["Ambient1"], shader.inputs.get("Ambient Color")) + __init_link(node.inputs["Diffuse1"], shader.inputs.get("Diffuse Color")) + __init_link(node.inputs["Specular1"], shader.inputs.get("Specular Color")) + __init_link(node.inputs["Reflect1"], shader.inputs.get("Reflect")) + __init_link(node.inputs["Alpha1"], shader.inputs.get("Alpha")) + __init_link(node.inputs["Base1 RGB"], shader.inputs.get("Base Tex")) + __init_link(node.inputs["Toon1 RGB"], shader.inputs.get("Toon Tex")) # FIXME toon only affect shadow color + __init_link(node.inputs["Sphere1 RGB"], shader.inputs.get("Sphere Tex")) + elif "mmd_edge_preview" in nodes: + shader = nodes["mmd_edge_preview"] + __init_link(node.inputs["Edge1 RGB"], shader.inputs["Color"]) + __init_link(node.inputs["Edge1 A"], shader.inputs["Alpha"]) + + @classmethod + def __update_node_inputs(cls, node, morph): + node.inputs["Ambient2"].default_value[:3] = morph.ambient_color[:3] + node.inputs["Diffuse2"].default_value[:3] = morph.diffuse_color[:3] + node.inputs["Specular2"].default_value[:3] = morph.specular_color[:3] + node.inputs["Reflect2"].default_value = morph.shininess + node.inputs["Alpha2"].default_value = morph.diffuse_color[3] + + node.inputs["Edge2 RGB"].default_value[:3] = morph.edge_color[:3] + node.inputs["Edge2 A"].default_value = morph.edge_color[3] + + node.inputs["Base2 RGB"].default_value[:3] = morph.texture_factor[:3] + node.inputs["Base2 A"].default_value = morph.texture_factor[3] + node.inputs["Toon2 RGB"].default_value[:3] = morph.toon_texture_factor[:3] + node.inputs["Toon2 A"].default_value = morph.toon_texture_factor[3] + node.inputs["Sphere2 RGB"].default_value[:3] = morph.sphere_texture_factor[:3] + node.inputs["Sphere2 A"].default_value = morph.sphere_texture_factor[3] + + @classmethod + def __morph_node_add(cls, material, morph, prev_node): + nodes, links = material.node_tree.nodes, material.node_tree.links + + shader = nodes.get("mmd_shader", None) + if morph: + node = nodes.new("ShaderNodeGroup") + node.parent = getattr(shader, "parent", None) + node.location = (-250, 0) + node.node_tree = cls.__get_shader("Add" if morph.offset_type == "ADD" else "Mul") + cls.__update_node_inputs(node, morph) + if prev_node: + for id_name in ("Ambient", "Diffuse", "Specular", "Reflect", "Alpha"): + links.new(prev_node.outputs[id_name], node.inputs[id_name + "1"]) + for id_name in ("Edge", "Base", "Toon", "Sphere"): + links.new(prev_node.outputs[id_name + " RGB"], node.inputs[id_name + "1 RGB"]) + links.new(prev_node.outputs[id_name + " A"], node.inputs[id_name + "1 A"]) + else: # initial first node + if node.node_tree.name.endswith("Add"): + node.inputs["Base1 A"].default_value = 1 + node.inputs["Toon1 A"].default_value = 1 + node.inputs["Sphere1 A"].default_value = 1 + cls.__update_morph_links(node) + return node + # connect last node to shader + if shader: + + def __soft_link(socket_out, socket_in): + if socket_out and socket_in: + links.new(socket_out, socket_in) + + __soft_link(prev_node.outputs["Ambient"], shader.inputs.get("Ambient Color")) + __soft_link(prev_node.outputs["Diffuse"], shader.inputs.get("Diffuse Color")) + __soft_link(prev_node.outputs["Specular"], shader.inputs.get("Specular Color")) + __soft_link(prev_node.outputs["Reflect"], shader.inputs.get("Reflect")) + __soft_link(prev_node.outputs["Alpha"], shader.inputs.get("Alpha")) + __soft_link(prev_node.outputs["Base Tex"], shader.inputs.get("Base Tex")) + __soft_link(prev_node.outputs["Toon Tex"], shader.inputs.get("Toon Tex")) + if int(material.mmd_material.sphere_texture_type) != 2: # shader.inputs['Sphere Mul/Add'].default_value < 0.5 + __soft_link(prev_node.outputs["Sphere Tex"], shader.inputs.get("Sphere Tex")) + else: + __soft_link(prev_node.outputs["Sphere Tex Add"], shader.inputs.get("Sphere Tex")) + elif "mmd_edge_preview" in nodes: + shader = nodes["mmd_edge_preview"] + links.new(prev_node.outputs["Edge RGB"], shader.inputs["Color"]) + links.new(prev_node.outputs["Edge A"], shader.inputs["Alpha"]) + return shader + + @classmethod + def __get_shader(cls, morph_type): + group_name = "MMDMorph" + morph_type + shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + if len(shader.nodes): + return shader + + ng = _NodeGroupUtils(shader) + links = ng.links + + use_mul = morph_type == "Mul" + + ############################################################################ + node_input = ng.new_node("NodeGroupInput", (-3, 0)) + ng.new_input_socket("Fac", None, 0, socket_type="NodeSocketFloat") + ng.new_node("NodeGroupOutput", (3, 0)) + + def __blend_color_add(id_name, pos, tag=""): + # MA_RAMP_MULT: ColorMul = Color1 * (Fac * Color2 + (1 - Fac)) + # MA_RAMP_ADD: ColorAdd = Color1 + Fac * Color2 + # https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/source/blender/blenkernel/intern/material.c#L1400 + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos[0] + 1, pos[1])) + links.new(node_input.outputs["Fac"], node_mix.inputs["Fac"]) + ng.new_input_socket("%s1" % id_name + tag, node_mix.inputs["Color1"]) + ng.new_input_socket("%s2" % id_name + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector") + ng.new_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): + # Tex Color = tex_rgb * tex_a + (1 - tex_a) + # : tex_rgb = TexRGB * ColorMul + ColorAdd + # : tex_a = TexA * ValueMul + ValueAdd + if id_name != "Sphere": + node_mix = ng.new_mix_node("MULTIPLY", pos, color1=(1, 1, 1, 1)) + links.new(node_tex_a_output, node_mix.inputs[0]) + links.new(node_tex_rgb.outputs["Color"], node_mix.inputs[2]) + ng.new_output_socket(id_name + " Tex", node_mix.outputs[0]) + else: + node_inv = ng.new_math_node("SUBTRACT", (pos[0], pos[1] - 0.25), value1=1.0) + node_scale = ng.new_vector_math_node("SCALE", (pos[0], pos[1])) + node_add = ng.new_vector_math_node("ADD", (pos[0] + 1, pos[1])) + + links.new(node_tex_a_output, node_inv.inputs[1]) + links.new(node_tex_rgb.outputs["Color"], node_scale.inputs[0]) + links.new(node_tex_a_output, node_scale.inputs["Scale"]) + links.new(node_scale.outputs[0], node_add.inputs[0]) + links.new(node_inv.outputs[0], node_add.inputs[1]) + + 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=""): + 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) + + pos_x = -2 + __blend_color_add("Ambient", (pos_x, +0.5)) + __blend_color_add("Diffuse", (pos_x, +0.0)) + __blend_color_add("Specular", (pos_x, -0.5)) + + combine_reflect1_alpha1_edge1 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.5)) + combine_reflect2_alpha2_edge2 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.75)) + separate_reflect_alpha_edge = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -1.5)) + + __add_sockets("Reflect", combine_reflect1_alpha1_edge1.inputs[0], combine_reflect2_alpha2_edge2.inputs[0], separate_reflect_alpha_edge.outputs[0]) + __add_sockets("Alpha", combine_reflect1_alpha1_edge1.inputs[1], combine_reflect2_alpha2_edge2.inputs[1], separate_reflect_alpha_edge.outputs[1]) + + __blend_color_add("Edge", (pos_x, -1.0), " RGB") + __add_sockets("Edge", combine_reflect1_alpha1_edge1.inputs[2], combine_reflect2_alpha2_edge2.inputs[2], separate_reflect_alpha_edge.outputs[2], tag=" A") + + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -1.5)) + links.new(node_input.outputs["Fac"], node_mix.inputs[0]) + links.new(combine_reflect1_alpha1_edge1.outputs[0], node_mix.inputs[1]) + links.new(combine_reflect2_alpha2_edge2.outputs[0], node_mix.inputs[2]) + links.new(node_mix.outputs[0], separate_reflect_alpha_edge.inputs[0]) + + combine_base1a_toon1a_sphere1a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.0)) + combine_base2a_toon2a_sphere2a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.25)) + separate_basea_toona_spherea = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -2.0)) + + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -2.0)) + links.new(node_input.outputs["Fac"], node_mix.inputs[0]) + links.new(combine_base1a_toon1a_sphere1a.outputs[0], node_mix.inputs[1]) + links.new(combine_base2a_toon2a_sphere2a.outputs[0], node_mix.inputs[2]) + links.new(node_mix.outputs[0], separate_basea_toona_spherea.inputs[0]) + + base_rgb = __blend_color_add("Base", (pos_x, -2.5), " RGB") + __add_sockets("Base", combine_base1a_toon1a_sphere1a.inputs[0], combine_base2a_toon2a_sphere2a.inputs[0], separate_basea_toona_spherea.outputs[0], tag=" A") + __blend_tex_color("Base", (pos_x + 3, -2.5), base_rgb, separate_basea_toona_spherea.outputs[0]) + + toon_rgb = __blend_color_add("Toon", (pos_x, -3.0), " RGB") + __add_sockets("Toon", combine_base1a_toon1a_sphere1a.inputs[1], combine_base2a_toon2a_sphere2a.inputs[1], separate_basea_toona_spherea.outputs[1], tag=" A") + __blend_tex_color("Toon", (pos_x + 3, -3.0), toon_rgb, separate_basea_toona_spherea.outputs[1]) + + sphere_rgb = __blend_color_add("Sphere", (pos_x, -3.5), " RGB") + __add_sockets("Sphere", combine_base1a_toon1a_sphere1a.inputs[2], combine_base2a_toon2a_sphere2a.inputs[2], separate_basea_toona_spherea.outputs[2], tag=" A") + __blend_tex_color("Sphere", (pos_x + 3, -3.5), sphere_rgb, separate_basea_toona_spherea.outputs[2]) + + ng.hide_nodes() + return ng.shader diff --git a/core/mmd/core/translations.py b/core/mmd/core/translations.py new file mode 100644 index 0000000..6574ba0 --- /dev/null +++ b/core/mmd/core/translations.py @@ -0,0 +1,738 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +import re +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple + +import bpy + +from ..translations import DictionaryEnum +from ..utils import convertLRToName, convertNameToLR +from .model import FnModel, Model + +if TYPE_CHECKING: + from ..properties.morph import _MorphBase + from ..properties.root import MMDRoot + from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex + + +class MMDTranslationElementType(Enum): + BONE = "Bones" + MORPH = "Morphs" + MATERIAL = "Materials" + DISPLAY = "Display" + PHYSICS = "Physics" + INFO = "Information" + + +class MMDDataHandlerABC(ABC): + @classmethod + @property + @abstractmethod + def type_name(cls) -> str: + pass + + @classmethod + @abstractmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + pass + + @classmethod + @abstractmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + pass + + @classmethod + @abstractmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + pass + + @classmethod + @abstractmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + pass + + @classmethod + @abstractmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + pass + + @classmethod + @abstractmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + """Returns (name, name_j, name_e)""" + + @classmethod + def is_restorable(cls, mmd_translation_element: "MMDTranslationElement") -> bool: + return (mmd_translation_element.name, mmd_translation_element.name_j, mmd_translation_element.name_e) != cls.get_names(mmd_translation_element) + + @classmethod + def check_data_visible(cls, filter_selected: bool, filter_visible: bool, select: bool, hide: bool) -> bool: + return filter_selected and not select or filter_visible and hide + + @classmethod + def prop_restorable(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str, original_value: str, index: int): + row = layout.row(align=True) + row.prop(mmd_translation_element, prop_name, text="") + + if getattr(mmd_translation_element, prop_name) == original_value: + row.label(text="", icon="BLANK1") + return + + op = row.operator("mmd_tools.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH") + op.index = index + op.prop_name = prop_name + op.restore_value = original_value + + @classmethod + def prop_disabled(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str): + row = layout.row(align=True) + row.enabled = False + row.prop(mmd_translation_element, prop_name, text="") + row.label(text="", icon="BLANK1") + + +class MMDBoneHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.BONE.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="BONE_DATA") + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", pose_bone.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", pose_bone.mmd_bone.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", pose_bone.mmd_bone.name_e, index) + row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.bone.select else "RESTRICT_SELECT_ON") + row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if pose_bone.bone.hide else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) + pose_bone: bpy.types.PoseBone + for index, pose_bone in enumerate(armature_object.pose.bones): + if not any(c.is_visible for c in pose_bone.bone.collections): + continue + + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.BONE.name + mmd_translation_element.object = armature_object + mmd_translation_element.data_path = f"pose.bones[{index}]" + mmd_translation_element.name = pose_bone.name + mmd_translation_element.name_j = pose_bone.mmd_bone.name_j + mmd_translation_element.name_e = pose_bone.mmd_bone.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + mmd_translation_element.object.id_data.data.bones.active = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path).bone + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.BONE.name: + continue + + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + + if cls.check_data_visible(filter_selected, filter_visible, pose_bone.bone.select, pose_bone.bone.hide): + continue + + if check_blank_name(mmd_translation_element.name_j, mmd_translation_element.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + pose_bone.name = name + if name_j is not None: + pose_bone.mmd_bone.name_j = name_j + if name_e is not None: + pose_bone.mmd_bone.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (pose_bone.name, pose_bone.mmd_bone.name_j, pose_bone.mmd_bone.name_e) + + +class MMDMorphHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.MORPH.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="SHAPEKEY_DATA") + prop_row = row.row() + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_restorable(prop_row, mmd_translation_element, "name", morph.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", morph.name_e, index) + row.label(text="", icon="BLANK1") + row.label(text="", icon="BLANK1") + + MORPH_DATA_PATH_EXTRACT = re.compile(r"mmd_root\.(?P[^\[]*)\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + mmd_root: "MMDRoot" = root_object.mmd_root + + for morphs_name, morphs in { + "material_morphs": mmd_root.material_morphs, + "uv_morphs": mmd_root.uv_morphs, + "bone_morphs": mmd_root.bone_morphs, + "vertex_morphs": mmd_root.vertex_morphs, + "group_morphs": mmd_root.group_morphs, + }.items(): + morph: "_MorphBase" + for index, morph in enumerate(morphs): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.MORPH.name + mmd_translation_element.object = root_object + mmd_translation_element.data_path = f"mmd_root.{morphs_name}[{index}]" + mmd_translation_element.name = morph.name + # mmd_translation_element.name_j = None + mmd_translation_element.name_e = morph.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + match = cls.MORPH_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + mmd_translation_element.object.mmd_root.active_morph_type = match["morphs_name"] + mmd_translation_element.object.mmd_root.active_morph = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.MORPH.name: + continue + + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if check_blank_name(morph.name, morph.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + morph.name = name + if name_e is not None: + morph.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (morph.name, "", morph.name_e) + + +class MMDMaterialHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.MATERIAL.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + mesh_object: bpy.types.Object = mmd_translation_element.object + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="MATERIAL_DATA") + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", material.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", material.mmd_material.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", material.mmd_material.name_e, index) + row.prop(mesh_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mesh_object.select_get() else "RESTRICT_SELECT_ON") + row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mesh_object.hide_get() else "HIDE_OFF") + + MATERIAL_DATA_PATH_EXTRACT = re.compile(r"data\.materials\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + checked_materials: Set[bpy.types.Material] = set() + mesh_object: bpy.types.Object + for mesh_object in FnModel.iterate_mesh_objects(mmd_translation.id_data): + material: bpy.types.Material + for index, material in enumerate(mesh_object.data.materials): + if material in checked_materials: + continue + + checked_materials.add(material) + + if not hasattr(material, "mmd_material"): + continue + + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.MATERIAL.name + mmd_translation_element.object = mesh_object + mmd_translation_element.data_path = f"data.materials[{index}]" + mmd_translation_element.name = material.name + mmd_translation_element.name_j = material.mmd_material.name_j + mmd_translation_element.name_e = material.mmd_material.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + id_data: bpy.types.Object = mmd_translation_element.object + bpy.context.view_layer.objects.active = id_data + + match = cls.MATERIAL_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + id_data.active_material_index = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.MATERIAL.name: + continue + + mesh_object: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, mesh_object.select_get(), mesh_object.hide_get()): + continue + + material: bpy.types.Material = mesh_object.path_resolve(mmd_translation_element.data_path) + if check_blank_name(material.mmd_material.name_j, material.mmd_material.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + material.name = name + if name_j is not None: + material.mmd_material.name_j = name_j + if name_e is not None: + material.mmd_material.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (material.name, material.mmd_material.name_j, material.mmd_material.name_e) + + +class MMDDisplayHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.DISPLAY.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="GROUP_BONE") + + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", bone_collection.name, index) + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_disabled(prop_row, mmd_translation_element, "name_e") + row.prop(mmd_translation_element.object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mmd_translation_element.object.select_get() else "RESTRICT_SELECT_ON") + row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mmd_translation_element.object.hide_get() else "HIDE_OFF") + + DISPLAY_DATA_PATH_EXTRACT = re.compile(r"data\.collections\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) + bone_collection: bpy.types.BoneCollection + for index, bone_collection in enumerate(armature_object.data.collections): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.DISPLAY.name + mmd_translation_element.object = armature_object + mmd_translation_element.data_path = f"data.collections[{index}]" + mmd_translation_element.name = bone_collection.name + # mmd_translation_element.name_j = None + # mmd_translation_element.name_e = None + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + id_data: bpy.types.Object = mmd_translation_element.object + bpy.context.view_layer.objects.active = id_data + + match = cls.DISPLAY_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + id_data.data.collections.active_index = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.DISPLAY.name: + continue + + obj: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()): + continue + + bone_collection: bpy.types.BoneCollection = obj.path_resolve(mmd_translation_element.data_path) + if check_blank_name(bone_collection.name, ""): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + bone_collection.name = name + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (bone_collection.name, "", "") + + +class MMDPhysicsHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.PHYSICS.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + icon = "MESH_ICOSPHERE" + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + icon = "CONSTRAINT" + mmd_object = obj.mmd_joint + + row = layout.row(align=True) + row.label(text="", icon=icon) + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", obj.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", mmd_object.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", mmd_object.name_e, index) + row.prop(obj, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if obj.select_get() else "RESTRICT_SELECT_ON") + row.prop(obj, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if obj.hide_get() else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + model = Model(root_object) + + obj: bpy.types.Object + for obj in model.rigidBodies(): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name + mmd_translation_element.object = obj + mmd_translation_element.data_path = "mmd_rigid" + mmd_translation_element.name = obj.name + mmd_translation_element.name_j = obj.mmd_rigid.name_j + mmd_translation_element.name_e = obj.mmd_rigid.name_e + + obj: bpy.types.Object + for obj in model.joints(): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name + mmd_translation_element.object = obj + mmd_translation_element.data_path = "mmd_joint" + mmd_translation_element.name = obj.name + mmd_translation_element.name_j = obj.mmd_joint.name_j + mmd_translation_element.name_e = obj.mmd_joint.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.PHYSICS.name: + continue + + obj: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()): + continue + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + if check_blank_name(mmd_object.name_j, mmd_object.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + if name is not None: + obj.name = name + if name_j is not None: + mmd_object.name_j = name_j + if name_e is not None: + mmd_object.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + return (obj.name, mmd_object.name_j, mmd_object.name_e) + + +class MMDInfoHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.INFO.name + + TYPE_TO_ICONS = { + "EMPTY": "EMPTY_DATA", + "ARMATURE": "ARMATURE_DATA", + "MESH": "MESH_DATA", + } + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + info_object: bpy.types.Object = mmd_translation_element.object + row = layout.row(align=True) + row.label(text="", icon=MMDInfoHandler.TYPE_TO_ICONS.get(info_object.type, "OBJECT_DATA")) + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", info_object.name, index) + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_disabled(prop_row, mmd_translation_element, "name_e") + row.prop(info_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if info_object.select_get() else "RESTRICT_SELECT_ON") + row.prop(info_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if info_object.hide_get() else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + info_objects = [root_object] + armature_object = FnModel.find_armature_object(root_object) + if armature_object is not None: + info_objects.append(armature_object) + + for info_object in itertools.chain(info_objects, FnModel.iterate_mesh_objects(root_object)): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.INFO.name + mmd_translation_element.object = info_object + mmd_translation_element.data_path = "" + mmd_translation_element.name = info_object.name + # mmd_translation_element.name_j = None + # mmd_translation_element.name_e = None + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.INFO.name: + continue + + info_object: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, info_object.select_get(), info_object.hide_get()): + continue + + if check_blank_name(info_object.name, ""): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + info_object: bpy.types.Object = mmd_translation_element.object + if name is not None: + info_object.name = name + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + info_object: bpy.types.Object = mmd_translation_element.object + return (info_object.name, "", "") + + +MMD_DATA_HANDLERS: Set[MMDDataHandlerABC] = { + MMDBoneHandler, + MMDMorphHandler, + MMDMaterialHandler, + MMDDisplayHandler, + MMDPhysicsHandler, + MMDInfoHandler, +} + +MMD_DATA_TYPE_TO_HANDLERS: Dict[str, MMDDataHandlerABC] = {h.type_name: h for h in MMD_DATA_HANDLERS} + + +class FnTranslations: + @staticmethod + def apply_translations(root_object: bpy.types.Object): + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + mmd_translation_element_index: "MMDTranslationElementIndex" + for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] + name, name_j, name_e = handler.get_names(mmd_translation_element) + handler.set_names( + mmd_translation_element, + mmd_translation_element.name if mmd_translation_element.name != name else None, + mmd_translation_element.name_j if mmd_translation_element.name_j != name_j else None, + mmd_translation_element.name_e if mmd_translation_element.name_e != name_e else None, + ) + + @staticmethod + def execute_translation_batch(root_object: bpy.types.Object) -> Tuple[Dict[str, str], Optional[bpy.types.Text]]: + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + batch_operation_script = mmd_translation.batch_operation_script + if not batch_operation_script: + return ({}, None) + + translator = DictionaryEnum.get_translator(mmd_translation.dictionary) + + def translate(name: str) -> str: + if translator: + return translator.translate(name, name) + return name + + batch_operation_script_ast = compile(mmd_translation.batch_operation_script, "", "eval") + batch_operation_target: str = mmd_translation.batch_operation_target + + mmd_translation_element_index: "MMDTranslationElementIndex" + for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + + handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] + + name = mmd_translation_element.name + name_j = mmd_translation_element.name_j + name_e = mmd_translation_element.name_e + org_name, org_name_j, org_name_e = handler.get_names(mmd_translation_element) + + # pylint: disable=eval-used + result_name = str( + eval( + batch_operation_script_ast, + {"__builtins__": {}}, + { + "to_english": translate, + "to_mmd_lr": convertLRToName, + "to_blender_lr": convertNameToLR, + "name": name, + "name_j": name_j if name_j != "" else name, + "name_e": name_e if name_e != "" else name, + "org_name": org_name, + "org_name_j": org_name_j, + "org_name_e": org_name_e, + }, + ) + ) + + if batch_operation_target == "BLENDER": + mmd_translation_element.name = result_name + elif batch_operation_target == "JAPANESE": + mmd_translation_element.name_j = result_name + elif batch_operation_target == "ENGLISH": + mmd_translation_element.name_e = result_name + + return (translator.fails, translator.save_fails()) + + @staticmethod + def update_index(mmd_translation: "MMDTranslation"): + if mmd_translation.filtered_translation_element_indices_active_index < 0: + return + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices[mmd_translation.filtered_translation_element_indices_active_index] + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + + MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].update_index(mmd_translation_element) + + @staticmethod + def collect_data(mmd_translation: "MMDTranslation"): + mmd_translation.translation_elements.clear() + for handler in MMD_DATA_HANDLERS: + handler.collect_data(mmd_translation) + + @staticmethod + def update_query(mmd_translation: "MMDTranslation"): + mmd_translation.filtered_translation_element_indices.clear() + mmd_translation.filtered_translation_element_indices_active_index = -1 + + filter_japanese_blank: bool = mmd_translation.filter_japanese_blank + filter_english_blank: bool = mmd_translation.filter_english_blank + + filter_selected: bool = mmd_translation.filter_selected + filter_visible: bool = mmd_translation.filter_visible + + def check_blank_name(name_j: str, name_e: str) -> bool: + return filter_japanese_blank and name_j or filter_english_blank and name_e + + for handler in MMD_DATA_HANDLERS: + if handler.type_name in mmd_translation.filter_types: + handler.update_query(mmd_translation, filter_selected, filter_visible, check_blank_name) + + @staticmethod + def clear_data(mmd_translation: "MMDTranslation"): + mmd_translation.translation_elements.clear() + mmd_translation.filtered_translation_element_indices.clear() + mmd_translation.filtered_translation_element_indices_active_index = -1 + mmd_translation.filter_restorable = False diff --git a/core/mmd/core/vmd/__init__.py b/core/mmd/core/vmd/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/core/vmd/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/core/vmd/importer.py b/core/mmd/core/vmd/importer.py new file mode 100644 index 0000000..07eb925 --- /dev/null +++ b/core/mmd/core/vmd/importer.py @@ -0,0 +1,673 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import math +import os +from typing import Union + +import bpy +from mathutils import Quaternion, Vector + +from ... import utils +from .. import vmd +from ..camera import MMDCamera +from ..lamp import MMDLamp + + +class _MirrorMapper: + def __init__(self, data_map=None): + from ...operators.view import FlipPose + + self.__data_map = data_map + self.__flip_name = FlipPose.flip_name + + def get(self, name, default=None): + return self.__data_map.get(self.__flip_name(name), None) or self.__data_map.get(name, default) + + @staticmethod + def get_location(location): + return (-location[0], location[1], location[2]) + + @staticmethod + def get_rotation(rotation_xyzw): + return (rotation_xyzw[0], -rotation_xyzw[1], -rotation_xyzw[2], rotation_xyzw[3]) + + @staticmethod + def get_rotation3(rotation_xyz): + return (rotation_xyz[0], -rotation_xyz[1], -rotation_xyz[2]) + + +class RenamedBoneMapper: + def __init__(self, armObj=None, rename_LR_bones=True, use_underscore=False, translator=None): + self.__pose_bones = armObj.pose.bones if armObj else None + self.__rename_LR_bones = rename_LR_bones + self.__use_underscore = use_underscore + self.__translator = translator + + def init(self, armObj): + self.__pose_bones = armObj.pose.bones + return self + + def get(self, bone_name, default=None): + bl_bone_name = bone_name + if self.__rename_LR_bones: + bl_bone_name = utils.convertNameToLR(bl_bone_name, self.__use_underscore) + if self.__translator: + bl_bone_name = self.__translator.translate(bl_bone_name) + return self.__pose_bones.get(bl_bone_name, default) + + +class _InterpolationHelper: + def __init__(self, mat): + self.__indices = indices = [0, 1, 2] + l = sorted((-abs(mat[i][j]), i, j) for i in range(3) for j in range(3)) + _, i, j = l[0] + if i != j: + indices[i], indices[j] = indices[j], indices[i] + _, i, j = next(k for k in l if k[1] != i and k[2] != j) + if indices[i] != j: + idx = indices.index(j) + indices[i], indices[idx] = indices[idx], indices[i] + + def convert(self, interpolation_xyz): + return (interpolation_xyz[i] for i in self.__indices) + + +class BoneConverter: + def __init__(self, pose_bone, scale, invert=False): + mat = pose_bone.bone.matrix_local.to_3x3() + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + self.__mat = mat.transposed() + self.__scale = scale + if invert: + self.__mat.invert() + self.convert_interpolation = _InterpolationHelper(self.__mat).convert + + def convert_location(self, location): + return (self.__mat @ Vector(location)) * self.__scale + + def convert_rotation(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized() + + +class BoneConverterPoseMode: + def __init__(self, pose_bone, scale, invert=False): + mat = pose_bone.matrix.to_3x3() + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + self.__mat = mat.transposed() + self.__scale = scale + self.__mat_rot = pose_bone.matrix_basis.to_3x3() + self.__mat_loc = self.__mat_rot @ self.__mat + self.__offset = pose_bone.location.copy() + self.convert_location = self._convert_location + self.convert_rotation = self._convert_rotation + if invert: + self.__mat.invert() + self.__mat_rot.invert() + self.__mat_loc.invert() + self.convert_location = self._convert_location_inverted + self.convert_rotation = self._convert_rotation_inverted + self.convert_interpolation = _InterpolationHelper(self.__mat_loc).convert + + def _convert_location(self, location): + return self.__offset + (self.__mat_loc @ Vector(location)) * self.__scale + + def _convert_rotation(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + rot = Quaternion((self.__mat @ rot.axis) * -1, rot.angle) + return (self.__mat_rot @ rot.to_matrix()).to_quaternion() + + def _convert_location_inverted(self, location): + return (self.__mat_loc @ (Vector(location) - self.__offset)) * self.__scale + + def _convert_rotation_inverted(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + rot = (self.__mat_rot @ rot.to_matrix()).to_quaternion() + return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized() + + +class _FnBezier: + @classmethod + def from_fcurve(cls, kp0, kp1): + p0, p1, p2, p3 = kp0.co, kp0.handle_right, kp1.handle_left, kp1.co + if p1.x > p3.x: + t = (p3.x - p0.x) / (p1.x - p0.x) + p1 = (1 - t) * p0 + p1 * t + if p0.x > p2.x: + t = (p3.x - p0.x) / (p3.x - p2.x) + p2 = (1 - t) * p3 + p2 * t + return cls(p0, p1, p2, p3) + + def __init__(self, p0, p1, p2, p3): # assuming VMD's bezier or F-Curve's bezier + # assert(p0.x <= p1.x <= p3.x and p0.x <= p2.x <= p3.x) + self._p0, self._p1, self._p2, self._p3 = p0, p1, p2, p3 + + @property + def points(self): + return self._p0, self._p1, self._p2, self._p3 + + def split(self, t): + p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3 + p01t = (1 - t) * p0 + t * p1 + p12t = (1 - t) * p1 + t * p2 + p23t = (1 - t) * p2 + t * p3 + p012t = (1 - t) * p01t + t * p12t + p123t = (1 - t) * p12t + t * p23t + pt = (1 - t) * p012t + t * p123t + return _FnBezier(p0, p01t, p012t, pt), _FnBezier(pt, p123t, p23t, p3), pt + + def evaluate(self, t): + p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3 + p01t = (1 - t) * p0 + t * p1 + p12t = (1 - t) * p1 + t * p2 + p23t = (1 - t) * p2 + t * p3 + p012t = (1 - t) * p01t + t * p12t + p123t = (1 - t) * p12t + t * p23t + return (1 - t) * p012t + t * p123t + + def split_by_x(self, x): + return self.split(self.axis_to_t(x)) + + def evaluate_by_x(self, x): + return self.evaluate(self.axis_to_t(x)) + + def axis_to_t(self, val, axis=0): + p0, p1, p2, p3 = self._p0[axis], self._p1[axis], self._p2[axis], self._p3[axis] + a = p3 - p0 + 3 * (p1 - p2) + b = 3 * (p0 - 2 * p1 + p2) + c = 3 * (p1 - p0) + d = p0 - val + return next(self.__find_roots(a, b, c, d)) + + def find_critical(self): + p0, p1, p2, p3 = self._p0.y, self._p1.y, self._p2.y, self._p3.y + p_min, p_max = (p0, p3) if p0 < p3 else (p3, p0) + if p1 > p_max or p1 < p_min or p2 > p_max or p2 < p_min: + a = 3 * (p3 - p0 + 3 * (p1 - p2)) + b = 6 * (p0 - 2 * p1 + p2) + c = 3 * (p1 - p0) + yield from self.__find_roots(0, a, b, c) + + @staticmethod + def __find_roots(a, b, c, d): # a*t*t*t + b*t*t + c*t + d = 0 + # TODO fix precision errors (ex: t=0 and t=1) and improve performance + if a == 0: + if b == 0: + t = -d / c + if 0 <= t <= 1: + yield t + else: + D = c * c - 4 * b * d + if D < 0: + return + D = D**0.5 + b2 = 2 * b + t = (-c + D) / b2 + if 0 <= t <= 1: + yield t + t = (-c - D) / b2 + if 0 <= t <= 1: + yield t + return + + def _sqrt3(v): + return -((-v) ** (1 / 3)) if v < 0 else v ** (1 / 3) + + A = b * c / (6 * a * a) - b * b * b / (27 * a * a * a) - d / (2 * a) + B = c / (3 * a) - b * b / (9 * a * a) + b_3a = -b / (3 * a) + D = A * A + B * B * B + + if D > 0: + D = D**0.5 + t = b_3a + _sqrt3(A + D) + _sqrt3(A - D) + if 0 <= t <= 1: + yield t + elif D == 0: + t = b_3a + _sqrt3(A) * 2 + if 0 <= t <= 1: + yield t + t = b_3a - _sqrt3(A) + if 0 <= t <= 1: + yield t + else: + R = A / (-B * B * B) ** 0.5 + t = b_3a + 2 * (-B) ** 0.5 * math.cos(math.acos(R) / 3) + if 0 <= t <= 1: + yield t + t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) + 2 * math.pi) / 3) + if 0 <= t <= 1: + yield t + t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) - 2 * math.pi) / 3) + if 0 <= t <= 1: + yield t + + +class HasAnimationData: + animation_data: bpy.types.AnimData + + +class VMDImporter: + def __init__(self, filepath, scale=1.0, bone_mapper=None, use_pose_mode=False, convert_mmd_camera=True, convert_mmd_lamp=True, frame_margin=5, use_mirror=False, use_NLA=False): + self.__vmdFile = vmd.File() + self.__vmdFile.load(filepath=filepath) + logging.debug(str(self.__vmdFile.header)) + self.__scale = scale + self.__convert_mmd_camera = convert_mmd_camera + self.__convert_mmd_lamp = convert_mmd_lamp + self.__bone_mapper = bone_mapper + self.__bone_util_cls = BoneConverterPoseMode if use_pose_mode else BoneConverter + self.__frame_margin = frame_margin + 1 + self.__mirror = use_mirror + self.__use_NLA = use_NLA + + @staticmethod + def __minRotationDiff(prev_q, curr_q): + t1 = (prev_q.w - curr_q.w) ** 2 + (prev_q.x - curr_q.x) ** 2 + (prev_q.y - curr_q.y) ** 2 + (prev_q.z - curr_q.z) ** 2 + t2 = (prev_q.w + curr_q.w) ** 2 + (prev_q.x + curr_q.x) ** 2 + (prev_q.y + curr_q.y) ** 2 + (prev_q.z + curr_q.z) ** 2 + # t1 = prev_q.rotation_difference(curr_q).angle + # t2 = prev_q.rotation_difference(-curr_q).angle + return -curr_q if t2 < t1 else curr_q + + @staticmethod + def __setInterpolation(bezier, kp0, kp1): + if bezier[0] == bezier[1] and bezier[2] == bezier[3]: + kp0.interpolation = "LINEAR" + else: + kp0.interpolation = "BEZIER" + kp0.handle_right_type = "FREE" + kp1.handle_left_type = "FREE" + d = (kp1.co - kp0.co) / 127.0 + kp0.handle_right = kp0.co + Vector((d.x * bezier[0], d.y * bezier[1])) + kp1.handle_left = kp0.co + Vector((d.x * bezier[2], d.y * bezier[3])) + + @staticmethod + def __fixFcurveHandles(fcurve): + kp0 = fcurve.keyframe_points[0] + kp0.handle_left_type = "FREE" + kp0.handle_left = kp0.co + Vector((-1, 0)) + kp = fcurve.keyframe_points[-1] + kp.handle_right_type = "FREE" + kp.handle_right = kp.co + Vector((1, 0)) + + @staticmethod + def __keyframe_insert_inner(fcurves: bpy.types.ActionFCurves, path: str, index: int, frame: float, value: float): + fcurve = fcurves.find(path, index=index) + if fcurve is None: + fcurve = fcurves.new(path, index=index) + fcurve.keyframe_points.insert(frame, value, options={"FAST"}) + + @staticmethod + def __keyframe_insert(fcurves: bpy.types.ActionFCurves, path: str, frame: float, value: Union[int, float, Vector]): + if isinstance(value, (int, float)): + VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value) + + elif isinstance(value, Vector): + VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value[0]) + VMDImporter.__keyframe_insert_inner(fcurves, path, 1, frame, value[1]) + VMDImporter.__keyframe_insert_inner(fcurves, path, 2, frame, value[2]) + + else: + raise TypeError("Unsupported type: {0}".format(type(value))) + + def __getBoneConverter(self, bone): + converter = self.__bone_util_cls(bone, self.__scale) + mode = bone.rotation_mode + compatible_quaternion = self.__minRotationDiff + + class _ConverterWrap: + convert_location = converter.convert_location + convert_interpolation = converter.convert_interpolation + if mode == "QUATERNION": + convert_rotation = converter.convert_rotation + compatible_rotation = compatible_quaternion + elif mode == "AXIS_ANGLE": + + @staticmethod + def convert_rotation(rot): + (x, y, z), angle = converter.convert_rotation(rot).to_axis_angle() + return (angle, x, y, z) + + @staticmethod + def compatible_rotation(prev, curr): + angle, x, y, z = curr + if prev[1] * x + prev[2] * y + prev[3] * z < 0: + angle, x, y, z = -angle, -x, -y, -z + angle_diff = prev[0] - angle + if abs(angle_diff) > math.pi: + pi_2 = math.pi * 2 + bias = -0.5 if angle_diff < 0 else 0.5 + angle += int(bias + angle_diff / pi_2) * pi_2 + return (angle, x, y, z) + + else: + convert_rotation = lambda rot: converter.convert_rotation(rot).to_euler(mode) + compatible_rotation = lambda prev, curr: curr.make_compatible(prev) or curr + + return _ConverterWrap + + def __assign_action(self, target: Union[bpy.types.ID, HasAnimationData], action: bpy.types.Action): + if target.animation_data is None: + target.animation_data_create() + + if not self.__use_NLA: + target.animation_data.action = action + else: + frame_current = bpy.context.scene.frame_current + target_track: bpy.types.NlaTrack = target.animation_data.nla_tracks.new() + target_track.name = action.name + target_strip = target_track.strips.new(action.name, frame_current, action) + target_strip.blend_type = "COMBINE" + + def __assignToArmature(self, armObj, action_name=None): + boneAnim = self.__vmdFile.boneAnimation + logging.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name) + if len(boneAnim) < 1: + return + + action_name = action_name or armObj.name + action = bpy.data.actions.new(name=action_name) + + extra_frame = 1 if self.__frame_margin > 1 else 0 + + pose_bones = armObj.pose.bones + if self.__bone_mapper: + pose_bones = self.__bone_mapper(armObj) + + _loc = _rot = lambda i: i + if self.__mirror: + pose_bones = _MirrorMapper(pose_bones) + _loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation + + class _Dummy: + pass + + dummy_keyframe_points = iter(lambda: _Dummy, None) + prop_rot_map = {"QUATERNION": "rotation_quaternion", "AXIS_ANGLE": "rotation_axis_angle"} + + bone_name_table = {} + for name, keyFrames in boneAnim.items(): + num_frame = len(keyFrames) + if num_frame < 1: + continue + bone = pose_bones.get(name, None) + if bone is None: + logging.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames)) + continue + logging.info("(bone) frames:%5d name: %s", len(keyFrames), name) + assert bone_name_table.get(bone.name, name) == name + bone_name_table[bone.name] = name + + fcurves = [dummy_keyframe_points] * 7 # x, y, z, r0, r1, r2, (r3) + data_path_rot = prop_rot_map.get(bone.rotation_mode, "rotation_euler") + bone_rotation = getattr(bone, data_path_rot) + default_values = list(bone.location) + list(bone_rotation) + data_path = 'pose.bones["%s"].location' % bone.name + for axis_i in range(3): + fcurves[axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name) + data_path = 'pose.bones["%s"].%s' % (bone.name, data_path_rot) + for axis_i in range(len(bone_rotation)): + fcurves[3 + axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name) + + for i in range(len(default_values)): + c = fcurves[i] + c.keyframe_points.add(extra_frame + num_frame) + kp_iter = iter(c.keyframe_points) + if extra_frame: + kp = next(kp_iter) + kp.co = (1, default_values[i]) + kp.interpolation = "LINEAR" + fcurves[i] = kp_iter + + converter = self.__getBoneConverter(bone) + prev_rot = bone_rotation if extra_frame else None + prev_kps, indices = None, tuple(converter.convert_interpolation((0, 16, 32))) + (48,) * len(bone_rotation) + keyFrames.sort(key=lambda x: x.frame_number) + for k, x, y, z, r0, r1, r2, r3 in zip(keyFrames, *fcurves): + frame = k.frame_number + self.__frame_margin + loc = converter.convert_location(_loc(k.location)) + curr_rot = converter.convert_rotation(_rot(k.rotation)) + if prev_rot is not None: + curr_rot = converter.compatible_rotation(prev_rot, curr_rot) + # FIXME the rotation interpolation has slightly different result + # Blender: rot(x) = prev_rot*(1 - bezier(t)) + curr_rot*bezier(t) + # MMD: rot(x) = prev_rot.slerp(curr_rot, factor=bezier(t)) + prev_rot = curr_rot + + x.co = (frame, loc[0]) + y.co = (frame, loc[1]) + z.co = (frame, loc[2]) + r0.co = (frame, curr_rot[0]) + r1.co = (frame, curr_rot[1]) + r2.co = (frame, curr_rot[2]) + r3.co = (frame, curr_rot[-1]) + + curr_kps = (x, y, z, r0, r1, r2, r3) + if prev_kps is not None: + interp = k.interp + for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps): + self.__setInterpolation(interp[idx : idx + 16 : 4], prev_kp, kp) + prev_kps = curr_kps + + for c in action.fcurves: + self.__fixFcurveHandles(c) + + # property animation + propertyAnim = self.__vmdFile.propertyAnimation + if len(propertyAnim) > 0: + logging.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name) + for keyFrame in propertyAnim: + logging.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states) + frame = keyFrame.frame_number + self.__frame_margin + for ikName, enable in keyFrame.ik_states: + bone = pose_bones.get(ikName, None) + if not bone: + continue + + self.__keyframe_insert(action.fcurves, f'pose.bones["{bone.name}"].mmd_ik_toggle', frame, enable) + + self.__assign_action(armObj, action) + + # Ensure IK toggle state is set based on the first frame of VMD animation + if len(propertyAnim) > 0: + # Collect IK states from the first frame + first_frame_ik_states = {} + first_frame = float('inf') + for keyFrame in propertyAnim: + frame_num = keyFrame.frame_number + if frame_num < first_frame: + first_frame = frame_num + for ikName, enable in keyFrame.ik_states: + first_frame_ik_states[ikName] = enable + elif frame_num == first_frame: + for ikName, enable in keyFrame.ik_states: + if ikName not in first_frame_ik_states: + first_frame_ik_states[ikName] = enable + # Set the mmd_ik_toggle property for each bone based on the collected first frame IK states + for ikName, enable in first_frame_ik_states.items(): + bone = pose_bones.get(ikName, None) + if bone and bone.mmd_ik_toggle != enable: + bone.mmd_ik_toggle = enable # This will trigger the _pose_bone_update_mmd_ik_toggle method + + def __assignToMesh(self, meshObj, action_name=None): + shapeKeyAnim = self.__vmdFile.shapeKeyAnimation + logging.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name) + if len(shapeKeyAnim) < 1: + return + + action_name = action_name or meshObj.name + action = bpy.data.actions.new(name=action_name) + + mirror_map = _MirrorMapper(meshObj.data.shape_keys.key_blocks) if self.__mirror else {} + shapeKeyDict = {k: mirror_map.get(k, v) for k, v in meshObj.data.shape_keys.key_blocks.items()} + + from math import ceil, floor + + for name, keyFrames in shapeKeyAnim.items(): + if name not in shapeKeyDict: + logging.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames)) + continue + logging.info("(mesh) frames:%5d name: %s", len(keyFrames), name) + shapeKey = shapeKeyDict[name] + fcurve = action.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name) + fcurve.keyframe_points.add(len(keyFrames)) + keyFrames.sort(key=lambda x: x.frame_number) + for k, v in zip(keyFrames, fcurve.keyframe_points): + v.co = (k.frame_number + self.__frame_margin, k.weight) + v.interpolation = "LINEAR" + weights = tuple(i.weight for i in keyFrames) + shapeKey.slider_min = min(shapeKey.slider_min, floor(min(weights))) + shapeKey.slider_max = max(shapeKey.slider_max, ceil(max(weights))) + + self.__assign_action(meshObj.data.shape_keys, action) + + def __assignToRoot(self, rootObj, action_name=None): + propertyAnim = self.__vmdFile.propertyAnimation + logging.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name) + if len(propertyAnim) < 1: + return + + action_name = action_name or rootObj.name + action = bpy.data.actions.new(name=action_name) + + logging.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim]) + for keyFrame in propertyAnim: + self.__keyframe_insert(action.fcurves, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible)) + + self.__assign_action(rootObj, action) + + @staticmethod + def detectCameraChange(fcurve, threshold=10.0): + frames = list(fcurve.keyframe_points) + frameCount = len(frames) + frames.sort(key=lambda x: x.co[0]) + for i, f in enumerate(frames): + if i + 1 < frameCount: + n = frames[i + 1] + if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold: + f.interpolation = "CONSTANT" + + def __assignToCamera(self, cameraObj, action_name=None): + mmdCameraInstance = MMDCamera.convertToMMDCamera(cameraObj, self.__scale) + mmdCamera = mmdCameraInstance.object() + cameraObj = mmdCameraInstance.camera() + + cameraAnim = self.__vmdFile.cameraAnimation + logging.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name) + if len(cameraAnim) < 1: + return + + action_name = action_name or mmdCamera.name + parent_action = bpy.data.actions.new(name=action_name) + distance_action = bpy.data.actions.new(name=action_name + "_dis") + + _loc = _rot = lambda i: i + if self.__mirror: + _loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation3 + + 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(len(cameraAnim)) + + prev_kps, indices = None, (0, 8, 4, 12, 12, 12, 16, 20) # x, z, y, rx, ry, rz, dis, fov + cameraAnim.sort(key=lambda x: x.frame_number) + for k, x, y, z, rx, ry, rz, fov, persp, dis in zip(cameraAnim, *(c.keyframe_points for c in fcurves)): + frame = k.frame_number + self.__frame_margin + x.co, z.co, y.co = ((frame, val * self.__scale) for val in _loc(k.location)) + rx.co, rz.co, ry.co = ((frame, val) for val in _rot(k.rotation)) + fov.co = (frame, math.radians(k.angle)) + dis.co = (frame, k.distance * self.__scale) + persp.co = (frame, k.persp) + + persp.interpolation = "CONSTANT" + curr_kps = (x, y, z, rx, ry, rz, dis, fov) + if prev_kps is not None: + interp = k.interp + for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps): + self.__setInterpolation(interp[idx : idx + 4 : 2] + interp[idx + 1 : idx + 4 : 2], prev_kp, kp) + prev_kps = curr_kps + + for fcurve in fcurves: + self.__fixFcurveHandles(fcurve) + if fcurve.data_path == "rotation_euler": + self.detectCameraChange(fcurve) + + self.__assign_action(mmdCamera, parent_action) + self.__assign_action(cameraObj, distance_action) + + @staticmethod + def detectLampChange(fcurve, threshold=0.1): + frames = list(fcurve.keyframe_points) + frameCount = len(frames) + frames.sort(key=lambda x: x.co[0]) + for i, f in enumerate(frames): + f.interpolation = "LINEAR" + if i + 1 < frameCount: + n = frames[i + 1] + if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold: + f.interpolation = "CONSTANT" + + def __assignToLamp(self, lampObj, action_name=None): + mmdLampInstance = MMDLamp.convertToMMDLamp(lampObj, self.__scale) + mmdLamp = mmdLampInstance.object() + lampObj = mmdLampInstance.lamp() + + lampAnim = self.__vmdFile.lampAnimation + logging.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name) + if len(lampAnim) < 1: + return + + action_name = action_name or mmdLamp.name + color_action = bpy.data.actions.new(name=action_name + "_color") + location_action = bpy.data.actions.new(name=action_name + "_loc") + + _loc = _MirrorMapper.get_location if self.__mirror else lambda i: i + for keyFrame in lampAnim: + frame = keyFrame.frame_number + self.__frame_margin + self.__keyframe_insert(color_action.fcurves, "color", frame, Vector(keyFrame.color)) + self.__keyframe_insert(location_action.fcurves, "location", frame, Vector(_loc(keyFrame.direction)).xzy * -1) + + for fcurve in location_action.fcurves: + self.detectLampChange(fcurve) + + self.__assign_action(lampObj.data, color_action) + self.__assign_action(lampObj, location_action) + + def assign(self, obj, action_name=None): + if obj is None: + return + if action_name is None: + action_name = os.path.splitext(os.path.basename(self.__vmdFile.filepath))[0] + + if MMDCamera.isMMDCamera(obj): + self.__assignToCamera(obj, action_name + "_camera") + elif MMDLamp.isMMDLamp(obj): + self.__assignToLamp(obj, action_name + "_lamp") + elif getattr(obj.data, "shape_keys", None): + self.__assignToMesh(obj, action_name + "_facial") + elif obj.type == "ARMATURE": + self.__assignToArmature(obj, action_name + "_bone") + elif obj.type == "CAMERA" and self.__convert_mmd_camera: + self.__assignToCamera(obj, action_name + "_camera") + elif obj.type == "LAMP" and self.__convert_mmd_lamp: + self.__assignToLamp(obj, action_name + "_lamp") + elif obj.mmd_type == "ROOT": + self.__assignToRoot(obj, action_name + "_display") + else: + pass diff --git a/core/mmd/cycles_converter.py b/core/mmd/cycles_converter.py new file mode 100644 index 0000000..2a8e531 --- /dev/null +++ b/core/mmd/cycles_converter.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +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/core/mmd/operators/__init__.py b/core/mmd/operators/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/operators/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py new file mode 100644 index 0000000..23f2d49 --- /dev/null +++ b/core/mmd/operators/material.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy +from bpy.props import BoolProperty, StringProperty +from bpy.types import Operator + +from .. import cycles_converter +from ..core.exceptions import MaterialNotFoundError +from ..core.material import FnMaterial +from ..core.shader import _NodeGroupUtils + + +class ConvertMaterialsForCycles(Operator): + bl_idname = "mmd_tools.convert_materials_for_cycles" + bl_label = "Convert Materials For Cycles" + bl_description = "Convert materials of selected objects for Cycles." + bl_options = {"REGISTER", "UNDO"} + + use_principled: bpy.props.BoolProperty( + name="Convert to Principled BSDF", + description="Convert MMD shader nodes to Principled BSDF as well if enabled", + default=False, + options={"SKIP_SAVE"}, + ) + + clean_nodes: bpy.props.BoolProperty( + name="Clean Nodes", + description="Remove redundant nodes as well if enabled. Disable it to keep node data.", + default=False, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == "MESH"), None) + + def draw(self, context): + layout = self.layout + layout.prop(self, "use_principled") + layout.prop(self, "clean_nodes") + + def execute(self, context): + try: + context.scene.render.engine = "CYCLES" + except: + self.report({"ERROR"}, " * Failed to change to Cycles render engine.") + return {"CANCELLED"} + for obj in (x for x in context.selected_objects if x.type == "MESH"): + cycles_converter.convertToCyclesShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes) + return {"FINISHED"} + + +class ConvertMaterials(Operator): + bl_idname = "mmd_tools.convert_materials" + bl_label = "Convert Materials" + bl_description = "Convert materials of selected objects." + bl_options = {"REGISTER", "UNDO"} + + use_principled: bpy.props.BoolProperty( + name="Convert to Principled BSDF", + description="Convert MMD shader nodes to Principled BSDF as well if enabled", + default=True, + options={"SKIP_SAVE"}, + ) + + clean_nodes: bpy.props.BoolProperty( + name="Clean Nodes", + description="Remove redundant nodes as well if enabled. Disable it to keep node data.", + default=True, + options={"SKIP_SAVE"}, + ) + + subsurface: bpy.props.FloatProperty( + name="Subsurface", + default=0.001, + soft_min=0.000, + soft_max=1.000, + precision=3, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == "MESH"), None) + + def execute(self, context): + for obj in context.selected_objects: + if obj.type != "MESH": + continue + cycles_converter.convertToBlenderShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes, subsurface=self.subsurface) + return {"FINISHED"} + +class ConvertBSDFMaterials(Operator): + bl_idname = 'mmd_tools.convert_bsdf_materials' + bl_label = 'Convert Blender Materials' + bl_description = 'Convert materials of selected objects.' + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == 'MESH'), None) + + def execute(self, context): + for obj in context.selected_objects: + if obj.type != 'MESH': + continue + cycles_converter.convertToMMDShader(obj) + return {'FINISHED'} + +class _OpenTextureBase: + """Create a texture for mmd model material.""" + + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + filepath: StringProperty( + name="File Path", + description="Filepath used for importing the file", + maxlen=1024, + subtype="FILE_PATH", + ) + + use_filter_image: BoolProperty( + default=True, + options={"HIDDEN"}, + ) + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + +class OpenTexture(Operator, _OpenTextureBase): + bl_idname = "mmd_tools.material_open_texture" + bl_label = "Open Texture" + bl_description = "Create main texture of active material" + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.create_texture(self.filepath) + return {"FINISHED"} + + +class RemoveTexture(Operator): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_remove_texture" + bl_label = "Remove Texture" + bl_description = "Remove main texture of active material" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.remove_texture() + return {"FINISHED"} + + +class OpenSphereTextureSlot(Operator, _OpenTextureBase): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_open_sphere_texture" + bl_label = "Open Sphere Texture" + bl_description = "Create sphere texture of active material" + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.create_sphere_texture(self.filepath, context.active_object) + return {"FINISHED"} + + +class RemoveSphereTexture(Operator): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_remove_sphere_texture" + bl_label = "Remove Sphere Texture" + bl_description = "Remove sphere texture of active material" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.remove_sphere_texture() + return {"FINISHED"} + + +class MoveMaterialUp(Operator): + bl_idname = "mmd_tools.move_material_up" + bl_label = "Move Material Up" + bl_description = "Moves selected material one slot up" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + 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 + + def execute(self, context): + obj = context.active_object + current_idx = obj.active_material_index + prev_index = current_idx - 1 + try: + FnMaterial.swap_materials(obj, current_idx, prev_index, reverse=True, swap_slots=True) + except MaterialNotFoundError: + self.report({"ERROR"}, "Materials not found") + return {"CANCELLED"} + obj.active_material_index = prev_index + + return {"FINISHED"} + + +class MoveMaterialDown(Operator): + bl_idname = "mmd_tools.move_material_down" + bl_label = "Move Material Down" + bl_description = "Moves the selected material one slot down" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + 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 + + def execute(self, context): + obj = context.active_object + current_idx = obj.active_material_index + next_index = current_idx + 1 + try: + FnMaterial.swap_materials(obj, current_idx, next_index, reverse=True, swap_slots=True) + except MaterialNotFoundError: + self.report({"ERROR"}, "Materials not found") + return {"CANCELLED"} + obj.active_material_index = next_index + return {"FINISHED"} + + +class EdgePreviewSetup(Operator): + bl_idname = "mmd_tools.edge_preview_setup" + bl_label = "Edge Preview Setup" + bl_description = 'Preview toon edge settings of active model using "Solidify" modifier' + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + action: bpy.props.EnumProperty( + name="Action", + description="Select action", + items=[ + ("CREATE", "Create", "Create toon edge", 0), + ("CLEAN", "Clean", "Clear toon edge", 1), + ], + default="CREATE", + ) + + def execute(self, context): + from ..core.model import FnModel + + root = FnModel.find_root_object(context.active_object) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + if self.action == "CLEAN": + for obj in FnModel.iterate_mesh_objects(root): + self.__clean_toon_edge(obj) + else: + from ..bpyutils import Props + + 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)) + self.report({"INFO"}, "Created %d toon edge(s)" % counts) + return {"FINISHED"} + + def __clean_toon_edge(self, obj): + if "mmd_edge_preview" in obj.modifiers: + obj.modifiers.remove(obj.modifiers["mmd_edge_preview"]) + + if "mmd_edge_preview" in obj.vertex_groups: + obj.vertex_groups.remove(obj.vertex_groups["mmd_edge_preview"]) + + FnMaterial.clean_materials(obj, can_remove=lambda m: m and m.name.startswith("mmd_edge.")) + + def __create_toon_edge(self, obj, scale=1.0): + self.__clean_toon_edge(obj) + materials = obj.data.materials + material_offset = len(materials) + for m in tuple(materials): + if m and m.mmd_material.enabled_toon_edge: + mat_edge = self.__get_edge_material("mmd_edge." + m.name, m.mmd_material.edge_color, materials) + materials.append(mat_edge) + elif material_offset > 1: + mat_edge = self.__get_edge_material("mmd_edge.disabled", (0, 0, 0, 0), materials) + materials.append(mat_edge) + if len(materials) > material_offset: + mod = obj.modifiers.get("mmd_edge_preview", None) + if mod is None: + mod = obj.modifiers.new("mmd_edge_preview", "SOLIDIFY") + mod.material_offset = material_offset + mod.thickness_vertex_group = 1e-3 # avoid overlapped faces + mod.use_flip_normals = True + mod.use_rim = False + mod.offset = 1 + self.__create_edge_preview_group(obj) + mod.thickness = scale + mod.vertex_group = "mmd_edge_preview" + return len(materials) - material_offset + + def __create_edge_preview_group(self, obj): + vertices, materials = obj.data.vertices, obj.data.materials + weight_map = {i: m.mmd_material.edge_weight for i, m in enumerate(materials) if m} + scale_map = {} + 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} + vg_edge_preview = obj.vertex_groups.new(name="mmd_edge_preview") + for i, mi in {v: f.material_index for f in reversed(obj.data.polygons) for v in f.vertices}.items(): + 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): + if mat_name in materials: + return materials[mat_name] + mat = bpy.data.materials.get(mat_name, None) + if mat is None: + mat = bpy.data.materials.new(mat_name) + mmd_mat = mat.mmd_material + # note: edge affects ground shadow + mmd_mat.is_double_sided = mmd_mat.enabled_drop_shadow = False + mmd_mat.enabled_self_shadow_map = mmd_mat.enabled_self_shadow = False + # mmd_mat.enabled_self_shadow_map = True # for blender 2.78+ BI viewport only + mmd_mat.diffuse_color = mmd_mat.specular_color = (0, 0, 0) + mmd_mat.ambient_color = edge_color[:3] + mmd_mat.alpha = edge_color[3] + mmd_mat.edge_color = edge_color + self.__make_shader(mat) + return mat + + def __make_shader(self, m): + m.use_nodes = True + nodes, links = m.node_tree.nodes, m.node_tree.links + + node_shader = nodes.get("mmd_edge_preview", None) + if node_shader is None or not any(s.is_linked for s in node_shader.outputs): + XPOS, YPOS = 210, 110 + nodes.clear() + node_shader = nodes.new("ShaderNodeGroup") + node_shader.name = "mmd_edge_preview" + node_shader.location = (0, 0) + node_shader.width = 200 + node_shader.node_tree = self.__get_edge_preview_shader() + + node_out = nodes.new("ShaderNodeOutputMaterial") + node_out.location = (XPOS * 2, YPOS * 0) + links.new(node_shader.outputs["Shader"], node_out.inputs["Surface"]) + + 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): + 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): + return shader + + ng = _NodeGroupUtils(shader) + + node_input = ng.new_node("NodeGroupInput", (-5, 0)) + node_output = ng.new_node("NodeGroupOutput", (3, 0)) + + ############################################################################ + node_color = ng.new_node("ShaderNodeMixRGB", (-1, -1.5)) + node_color.mute = True + + ng.new_input_socket("Color", node_color.inputs["Color1"]) + + ############################################################################ + node_ray = ng.new_node("ShaderNodeLightPath", (-3, 1.5)) + node_geo = ng.new_node("ShaderNodeNewGeometry", (-3, 0)) + node_max = ng.new_math_node("MAXIMUM", (-2, 1.5)) + node_max.mute = True + node_gt = ng.new_math_node("GREATER_THAN", (-1, 1)) + node_alpha = ng.new_math_node("MULTIPLY", (0, 1)) + node_trans = ng.new_node("ShaderNodeBsdfTransparent", (0, 0)) + node_rgb = ng.new_node("ShaderNodeBackground", (0, -0.5)) + node_mix = ng.new_node("ShaderNodeMixShader", (1, 0.5)) + + links = ng.links + links.new(node_ray.outputs["Is Camera Ray"], node_max.inputs[0]) + links.new(node_ray.outputs["Is Glossy Ray"], node_max.inputs[1]) + links.new(node_max.outputs["Value"], node_gt.inputs[0]) + links.new(node_geo.outputs["Backfacing"], node_gt.inputs[1]) + links.new(node_gt.outputs["Value"], node_alpha.inputs[0]) + links.new(node_alpha.outputs["Value"], node_mix.inputs["Fac"]) + links.new(node_trans.outputs["BSDF"], node_mix.inputs[1]) + links.new(node_rgb.outputs[0], node_mix.inputs[2]) + links.new(node_color.outputs["Color"], node_rgb.inputs["Color"]) + + ng.new_input_socket("Alpha", node_alpha.inputs[1]) + ng.new_output_socket("Shader", node_mix.outputs["Shader"]) + + return shader diff --git a/core/mmd/operators/misc.py b/core/mmd/operators/misc.py new file mode 100644 index 0000000..c59815e --- /dev/null +++ b/core/mmd/operators/misc.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import re + +import bpy + +from .. import utils +from ..bpyutils import FnContext, FnObject +from ..core.bone import FnBone +from ..core.model import FnModel, Model +from ..core.morph import FnMorph + + +class SelectObject(bpy.types.Operator): + bl_idname = "mmd_tools.object_select" + bl_label = "Select Object" + bl_description = "Select the object" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + name: bpy.props.StringProperty( + name="Name", + description="The object name", + default="", + options={"HIDDEN", "SKIP_SAVE"}, + ) + + def execute(self, context): + utils.selectAObject(context.scene.objects[self.name]) + return {"FINISHED"} + + +class MoveObject(bpy.types.Operator, utils.ItemMoveOp): + bl_idname = "mmd_tools.object_move" + bl_label = "Move Object" + bl_description = "Move active object up/down in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + __PREFIX_REGEXP = re.compile(r"(?P[0-9A-Z]{3}_)(?P.*)") + + @classmethod + def set_index(cls, obj, index): + m = cls.__PREFIX_REGEXP.match(obj.name) + name = m.group("name") if m else obj.name + obj.name = "%s_%s" % (utils.int2base(index, 36, 3), name) + + @classmethod + def get_name(cls, obj, prefix=None): + m = cls.__PREFIX_REGEXP.match(obj.name) + name = m.group("name") if m else obj.name + return name[len(prefix) :] if prefix and name.startswith(prefix) else name + + @classmethod + def normalize_indices(cls, objects): + for i, x in enumerate(objects): + cls.set_index(x, i) + + @classmethod + def poll(cls, context): + return context.active_object + + def execute(self, context): + obj = context.active_object + objects = self.__get_objects(obj) + if obj not in objects: + self.report({"ERROR"}, 'Can not move object "%s"' % obj.name) + return {"CANCELLED"} + + objects.sort(key=lambda x: x.name) + self.move(objects, objects.index(obj), self.type) + self.normalize_indices(objects) + return {"FINISHED"} + + def __get_objects(self, obj): + class __MovableList(list): + def move(self, index_old, index_new): + item = self[index_old] + self.remove(item) + self.insert(index_new, item) + + objects = [] + root = FnModel.find_root_object(obj) + if root: + rig = Model(root) + if obj.mmd_type == "NONE" and obj.type == "MESH": + objects = rig.meshes() + elif obj.mmd_type == "RIGID_BODY": + objects = rig.rigidBodies() + elif obj.mmd_type == "JOINT": + objects = rig.joints() + return __MovableList(objects) + + +class CleanShapeKeys(bpy.types.Operator): + bl_idname = "mmd_tools.clean_shape_keys" + bl_label = "Clean Shape Keys" + bl_description = "Remove unused shape keys of selected mesh objects" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return any(o.type == "MESH" for o in context.selected_objects) + + @staticmethod + def __can_remove(key_block): + if key_block.relative_key == key_block: + return False # Basis + for v0, v1 in zip(key_block.relative_key.data, key_block.data): + if v0.co != v1.co: + return False + return True + + def __shape_key_clean(self, obj, key_blocks): + for kb in key_blocks: + if self.__can_remove(kb): + FnObject.mesh_remove_shape_key(obj, kb) + if len(key_blocks) == 1: + FnObject.mesh_remove_shape_key(obj, key_blocks[0]) + + def execute(self, context): + obj: bpy.types.Object + for obj in context.selected_objects: + if obj.type != "MESH" or obj.data.shape_keys is None: + continue + if not obj.data.shape_keys.use_relative: + continue # not be considered yet + self.__shape_key_clean(obj, obj.data.shape_keys.key_blocks) + return {"FINISHED"} + + +class SeparateByMaterials(bpy.types.Operator): + bl_idname = "mmd_tools.separate_by_materials" + bl_label = "Separate By Materials" + bl_options = {"REGISTER", "UNDO"} + + clean_shape_keys: bpy.props.BoolProperty( + name="Clean Shape Keys", + description="Remove unused shape keys of separated objects", + default=True, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "MESH" + + def __separate_by_materials(self, obj): + utils.separateByMaterials(obj) + if self.clean_shape_keys: + bpy.ops.mmd_tools.clean_shape_keys() + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.__separate_by_materials(obj) + else: + bpy.ops.mmd_tools.clear_temp_materials() + bpy.ops.mmd_tools.clear_uv_morph_view() + + # Store the current material names + rig = Model(root) + mat_names = [getattr(mat, "name", None) for mat in rig.materials()] + self.__separate_by_materials(obj) + for mesh in rig.meshes(): + FnMorph.clean_uv_morph_vertex_groups(mesh) + if len(mesh.data.materials) > 0: + mat = mesh.data.materials[0] + idx = mat_names.index(getattr(mat, "name", None)) + MoveObject.set_index(mesh, idx) + + for morph in root.mmd_root.material_morphs: + FnMorph(morph, rig).update_mat_related_mesh() + utils.clearUnusedMeshes() + return {"FINISHED"} + + +class JoinMeshes(bpy.types.Operator): + bl_idname = "mmd_tools.join_meshes" + bl_label = "Join Meshes" + bl_description = "Join the Model meshes into a single one" + bl_options = {"REGISTER", "UNDO"} + + sort_shape_keys: bpy.props.BoolProperty( + name="Sort Shape Keys", + description="Sort shape keys in the order of vertex morph", + default=True, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + bpy.ops.mmd_tools.clear_temp_materials() + bpy.ops.mmd_tools.clear_uv_morph_view() + + # Find all the meshes in mmd_root + rig = Model(root) + meshes_list = sorted(rig.meshes(), key=lambda x: x.name) + if not meshes_list: + self.report({"ERROR"}, "The model does not have any meshes") + return {"CANCELLED"} + active_mesh = meshes_list[0] + + FnContext.select_objects(context, *meshes_list) + FnContext.set_active_object(context, active_mesh) + + # Store the current order of the materials + for m in meshes_list[1:]: + for mat in m.data.materials: + if mat not in active_mesh.data.materials[:]: + active_mesh.data.materials.append(mat) + + # Join selected meshes + bpy.ops.object.join() + + if self.sort_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: + FnMorph(morph, rig).update_mat_related_mesh(active_mesh) + utils.clearUnusedMeshes() + return {"FINISHED"} + + +class AttachMeshesToMMD(bpy.types.Operator): + bl_idname = "mmd_tools.attach_meshes" + bl_label = "Attach Meshes to Model" + bl_description = "Finds existing meshes and attaches them to the selected MMD model" + bl_options = {"REGISTER", "UNDO"} + + add_armature_modifier: bpy.props.BoolProperty(default=True) + + def execute(self, context: bpy.types.Context): + root = FnModel.find_root_object(context.active_object) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + armObj = FnModel.find_armature_object(root) + if armObj is None: + self.report({"ERROR"}, "Model Armature not found") + return {"CANCELLED"} + + FnModel.attach_mesh_objects(root, context.visible_objects, self.add_armature_modifier) + return {"FINISHED"} + + +class ChangeMMDIKLoopFactor(bpy.types.Operator): + bl_idname = "mmd_tools.change_mmd_ik_loop_factor" + bl_label = "Change MMD IK Loop Factor" + bl_description = "Multiplier for all bones' IK iterations in Blender" + bl_options = {"REGISTER", "UNDO"} + + mmd_ik_loop_factor: bpy.props.IntProperty( + name="MMD IK Loop Factor", + description="Scaling factor of MMD IK loop", + min=1, + soft_max=10, + max=100, + ) + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.active_object) is not None + + def invoke(self, context, event): + root_object = FnModel.find_root_object(context.active_object) + self.mmd_ik_loop_factor = root_object.mmd_root.ik_loop_factor + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + root_object = FnModel.find_root_object(context.active_object) + FnModel.change_mmd_ik_loop_factor(root_object, self.mmd_ik_loop_factor) + return {"FINISHED"} + + +class RecalculateBoneRoll(bpy.types.Operator): + bl_idname = "mmd_tools.recalculate_bone_roll" + bl_label = "Recalculate bone roll" + bl_description = "Recalculate bone roll for arm related bones" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "ARMATURE" + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + c = layout.column() + c.label(text="This operation will break existing f-curve/action.", icon="QUESTION") + c.label(text="Click [OK] to run the operation.") + + def execute(self, context): + arm = context.active_object + FnBone.apply_auto_bone_roll(arm) + return {"FINISHED"} diff --git a/core/mmd/operators/model.py b/core/mmd/operators/model.py new file mode 100644 index 0000000..16fe3ba --- /dev/null +++ b/core/mmd/operators/model.py @@ -0,0 +1,486 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from ..bpyutils import FnContext +from ..core.bone import FnBone, MigrationFnBone +from ..core.model import FnModel, Model + + +class MorphSliderSetup(bpy.types.Operator): + bl_idname = "mmd_tools.morph_slider_setup" + bl_label = "Morph Slider Setup" + bl_description = "Translate MMD morphs of selected object into format usable by Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("CREATE", "Create", "Create placeholder object for morph sliders", "SHAPEKEY_DATA", 0), + ("BIND", "Bind", "Bind morph sliders", "DRIVER", 1), + ("UNBIND", "Unbind", "Unbind morph sliders", "X", 2), + ], + default="CREATE", + ) + + def execute(self, context: bpy.types.Context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object): + rig = Model(root_object) + if self.type == "BIND": + rig.morph_slider.bind() + elif self.type == "UNBIND": + rig.morph_slider.unbind() + else: + rig.morph_slider.create() + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} + + +class CleanRiggingObjects(bpy.types.Operator): + bl_idname = "mmd_tools.clean_rig" + bl_label = "Clean Rig" + 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): + root_object = FnModel.find_root_object(context.active_object) + assert root_object is not None + + rig = Model(root_object) + rig.clean() + FnContext.set_active_object(context, root_object) + return {"FINISHED"} + + +class BuildRig(bpy.types.Operator): + bl_idname = "mmd_tools.build_rig" + bl_label = "Build Rig" + bl_description = "Translate physics of selected object into format usable by Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + non_collision_distance_scale: bpy.props.FloatProperty( + name="Non-Collision Distance Scale", + description="The distance scale for creating extra non-collision constraints while building physics", + min=0, + soft_max=10, + default=1.5, + ) + + collision_margin: bpy.props.FloatProperty( + name="Collision Margin", + description="The collision margin between rigid bodies. If 0, the default value for each shape is adopted.", + unit="LENGTH", + min=0, + soft_max=10, + default=1e-06, + ) + + def execute(self, context): + root_object = FnModel.find_root_object(context.active_object) + + with FnContext.temp_override_active_layer_collection(context, root_object): + rig = Model(root_object) + rig.build(self.non_collision_distance_scale, self.collision_margin) + FnContext.set_active_object(context, root_object) + + return {"FINISHED"} + + +class CleanAdditionalTransformConstraints(bpy.types.Operator): + bl_idname = "mmd_tools.clean_additional_transform" + bl_label = "Clean Additional Transform" + bl_description = "Delete shadow bones of selected object and revert bones to default MMD state" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + 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)) + FnContext.set_active_object(context, active_object) + return {"FINISHED"} + + +class ApplyAdditionalTransformConstraints(bpy.types.Operator): + bl_idname = "mmd_tools.apply_additional_transform" + bl_label = "Apply Additional Transform" + bl_description = "Translate appended bones of selected object for Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + armature_object = FnModel.find_armature_object(root_object) + assert armature_object is not None + + MigrationFnBone.fix_mmd_ik_limit_override(armature_object) + FnBone.apply_additional_transformation(armature_object) + FnContext.set_active_object(context, active_object) + return {"FINISHED"} + + +class SetupBoneFixedAxes(bpy.types.Operator): + bl_idname = "mmd_tools.bone_fixed_axis_setup" + bl_label = "Setup Bone Fixed Axis" + bl_description = "Setup fixed axis of selected bones" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("DISABLE", "Disable", "Disable MMD fixed axis of selected bones", 0), + ("LOAD", "Load", "Load/Enable MMD fixed axis of selected bones from their Y-axis or the only rotatable axis", 1), + ("APPLY", "Apply", "Align bone axes to MMD fixed axis of each bone", 2), + ], + default="LOAD", + ) + + def execute(self, context): + armature_object = context.active_object + if not armature_object or armature_object.type != "ARMATURE": + self.report({"ERROR"}, "Active object is not an armature object") + return {"CANCELLED"} + + if self.type == "APPLY": + FnBone.apply_bone_fixed_axis(armature_object) + FnBone.apply_additional_transformation(armature_object) + else: + FnBone.load_bone_fixed_axis(armature_object, enable=(self.type == "LOAD")) + return {"FINISHED"} + + +class SetupBoneLocalAxes(bpy.types.Operator): + bl_idname = "mmd_tools.bone_local_axes_setup" + bl_label = "Setup Bone Local Axes" + bl_description = "Setup local axes of each bone" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("DISABLE", "Disable", "Disable MMD local axes of selected bones", 0), + ("LOAD", "Load", "Load/Enable MMD local axes of selected bones from their bone axes", 1), + ("APPLY", "Apply", "Align bone axes to MMD local axes of each bone", 2), + ], + default="LOAD", + ) + + def execute(self, context): + armature_object = context.active_object + if not armature_object or armature_object.type != "ARMATURE": + self.report({"ERROR"}, "Active object is not an armature object") + return {"CANCELLED"} + + if self.type == "APPLY": + FnBone.apply_bone_local_axes(armature_object) + FnBone.apply_additional_transformation(armature_object) + else: + FnBone.load_bone_local_axes(armature_object, enable=(self.type == "LOAD")) + return {"FINISHED"} + + +class AddMissingVertexGroupsFromBones(bpy.types.Operator): + bl_idname = "mmd_tools.add_missing_vertex_groups_from_bones" + bl_label = "Add Missing Vertex Groups from Bones" + bl_description = "Add the missing vertex groups to the selected mesh" + bl_options = {"REGISTER", "UNDO"} + + search_in_all_meshes: bpy.props.BoolProperty( + name="Search in all meshes", + description="Search for vertex groups in all meshes", + default=False, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return FnModel.find_root_object(context.active_object) is not None + + def execute(self, context: bpy.types.Context): + active_object: bpy.types.Object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + bone_order_mesh_object = FnModel.find_bone_order_mesh_object(root_object) + if bone_order_mesh_object is None: + return {"CANCELLED"} + + FnModel.add_missing_vertex_groups_from_bones(root_object, bone_order_mesh_object, self.search_in_all_meshes) + + return {"FINISHED"} + + +class CreateMMDModelRoot(bpy.types.Operator): + bl_idname = "mmd_tools.create_mmd_model_root_object" + bl_label = "Create a MMD Model Root Object" + bl_description = "Create a MMD model root object with a basic armature" + bl_options = {"REGISTER", "UNDO"} + + name_j: bpy.props.StringProperty( + name="Name", + description="The name of the MMD model", + default="New MMD Model", + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="The english name of the MMD model", + default="New MMD Model", + ) + scale: bpy.props.FloatProperty( + name="Scale", + description="Scale", + default=0.08, + ) + + def execute(self, context): + rig = Model.create(self.name_j, self.name_e, self.scale, add_root_bone=True) + rig.initialDisplayFrames() + return {"FINISHED"} + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class ConvertToMMDModel(bpy.types.Operator): + bl_idname = "mmd_tools.convert_to_mmd_model" + bl_label = "Convert to a MMD Model" + bl_description = "Convert active armature with its meshes to a MMD model (experimental)" + bl_options = {"REGISTER", "UNDO"} + + ambient_color_source: bpy.props.EnumProperty( + name="Ambient Color Source", + description="Select ambient color source", + items=[ + ("DIFFUSE", "Diffuse", "Diffuse color", 0), + ("MIRROR", "Mirror", 'Mirror color (if property "mirror_color" is available)', 1), + ], + default="DIFFUSE", + ) + edge_threshold: bpy.props.FloatProperty( + name="Edge Threshold", + description="MMD toon edge will not be enabled if freestyle line color alpha less than this value", + min=0, + max=1.001, + precision=3, + step=0.1, + default=0.1, + ) + edge_alpha_min: bpy.props.FloatProperty( + name="Minimum Edge Alpha", + description="Minimum alpha of MMD toon edge color", + min=0, + max=1, + precision=3, + step=0.1, + default=0.5, + ) + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for converting the model", + default=0.08, + ) + convert_material_nodes: bpy.props.BoolProperty( + name="Convert Material Nodes", + default=True, + ) + middle_joint_bones_lock: bpy.props.BoolProperty( + name="Middle Joint Bones Lock", + description="Lock specific bones for backward compatibility.", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "ARMATURE" and obj.mode != "EDIT" + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + # TODO convert some basic MMD properties + armature_object = context.active_object + scale = self.scale + model_name = "New MMD Model" + + root_object = FnModel.find_root_object(armature_object) + if root_object is None or root_object != armature_object.parent: + 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): + if mesh.parent is None: + return False + return mesh.parent == armature_object or __is_child_of_armature(mesh.parent) + + def __is_using_armature(mesh): + for m in mesh.modifiers: + if m.type == "ARMATURE" and m.object == armature_object: + return True + return False + + def __get_root(mesh): + if mesh.parent is None: + return mesh + return __get_root(mesh.parent) + + for x in objects: + if __is_using_armature(x) and not __is_child_of_armature(x): + x_root = __get_root(x) + m = x_root.matrix_world + x_root.parent_type = "OBJECT" + x_root.parent = armature_object + x_root.matrix_world = m + + def __configure_rig(self, context: bpy.types.Context, mmd_model: Model): + root_object = mmd_model.rootObject() + armature_object = mmd_model.armature() + mesh_objects = tuple(mmd_model.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} + 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) + + from ..core.material import FnMaterial + + FnMaterial.set_nodes_are_readonly(not self.convert_material_nodes) + try: + 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 + if self.ambient_color_source == "MIRROR" and hasattr(m, "mirror_color"): + mmd_material.ambient_color = m.mirror_color + else: + mmd_material.ambient_color = [0.5 * c for c in mmd_material.diffuse_color] + + if hasattr(m, "line_color"): # freestyle line color + 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)] + finally: + FnMaterial.set_nodes_are_readonly(False) + from .display_item import DisplayItemQuickSetup + + FnBone.sync_display_item_frames_from_bone_collections(armature_object) + mmd_model.initialDisplayFrames(reset=False) # ensure default frames + DisplayItemQuickSetup.load_facial_items(root_object.mmd_root) + root_object.mmd_root.active_display_item_frame = 0 + + +class ResetObjectVisibility(bpy.types.Operator): + bl_idname = "mmd_tools.reset_object_visibility" + bl_label = "Reset Object Visivility" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context: bpy.types.Context): + 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): + 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 + + mmd_root_object.hide_set(False) + + rigid_group_object = FnModel.find_rigid_group_object(mmd_root_object) + if rigid_group_object: + rigid_group_object.hide_set(True) + + joint_group_object = FnModel.find_joint_group_object(mmd_root_object) + if joint_group_object: + joint_group_object.hide_set(True) + + temporary_group_object = FnModel.find_temporary_group_object(mmd_root_object) + if temporary_group_object: + temporary_group_object.hide_set(True) + + mmd_root.show_meshes = True + mmd_root.show_armature = True + mmd_root.show_temporary_objects = False + mmd_root.show_rigid_bodies = False + mmd_root.show_names_of_rigid_bodies = False + mmd_root.show_joints = False + mmd_root.show_names_of_joints = False + + return {"FINISHED"} + + +class AssembleAll(bpy.types.Operator): + bl_idname = "mmd_tools.assemble_all" + bl_label = "Assemble All" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object) as context: + rig = Model(root_object) + MigrationFnBone.fix_mmd_ik_limit_override(rig.armature()) + FnBone.apply_additional_transformation(rig.armature()) + rig.build() + rig.morph_slider.bind() + + with context.temp_override(selected_objects=[active_object]): + bpy.ops.mmd_tools.sdef_bind() + root_object.mmd_root.use_property_driver = True + + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} + + +class DisassembleAll(bpy.types.Operator): + bl_idname = "mmd_tools.disassemble_all" + bl_label = "Disassemble All" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object) as context: + root_object.mmd_root.use_property_driver = False + with context.temp_override(selected_objects=[active_object]): + bpy.ops.mmd_tools.sdef_unbind() + + rig = Model(root_object) + rig.morph_slider.unbind() + rig.clean() + FnBone.clean_additional_transformation(rig.armature()) + + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} diff --git a/core/mmd/operators/model_edit.py b/core/mmd/operators/model_edit.py new file mode 100644 index 0000000..ca21046 --- /dev/null +++ b/core/mmd/operators/model_edit.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +from operator import itemgetter +from typing import Dict, List, Optional, Set + +import bmesh +import bpy + +from ..bpyutils import FnContext +from ..core.model import FnModel, Model + + +class MessageException(Exception): + """Class for error with message.""" + + +class ModelJoinByBonesOperator(bpy.types.Operator): + bl_idname = "mmd_tools.model_join_by_bones" + bl_label = "Model Join by Bones" + bl_options = {"REGISTER", "UNDO"} + + join_type: bpy.props.EnumProperty( + name="Join Type", + items=[ + ("CONNECTED", "Connected", ""), + ("OFFSET", "Keep Offset", ""), + ], + default="OFFSET", + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object + + if context.mode != "POSE": + return False + + if active_object is None: + return False + + if active_object.type != "ARMATURE": + return False + + if len(list(filter(lambda o: o.type == "ARMATURE", context.selected_objects))) < 2: + return False + + return len(context.selected_pose_bones) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + try: + self.join(context) + except MessageException as ex: + self.report(type={"ERROR"}, message=str(ex)) + return {"CANCELLED"} + + return {"FINISHED"} + + def join(self, context: bpy.types.Context): + bpy.ops.object.mode_set(mode="OBJECT") + + parent_root_object = FnModel.find_root_object(context.active_object) + child_root_objects = {FnModel.find_root_object(o) for o in context.selected_objects} + child_root_objects.remove(parent_root_object) + + if parent_root_object is None or len(child_root_objects) == 0: + raise MessageException("No MMD Models selected") + + with FnContext.temp_override_active_layer_collection(context, parent_root_object): + FnModel.join_models(parent_root_object, child_root_objects) + + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.armature.parent_set(type="OFFSET") + + # Connect child bones + if self.join_type == "CONNECTED": + parent_edit_bone: bpy.types.EditBone = context.active_bone + child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + child_edit_bones.remove(parent_edit_bone) + + child_edit_bone: bpy.types.EditBone + for child_edit_bone in child_edit_bones: + child_edit_bone.use_connect = True + + bpy.ops.object.mode_set(mode="POSE") + + +class ModelSeparateByBonesOperator(bpy.types.Operator): + bl_idname = "mmd_tools.model_separate_by_bones" + bl_label = "Model Separate by Bones" + bl_options = {"REGISTER", "UNDO"} + + separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True) + include_descendant_bones: bpy.props.BoolProperty(name="Include Descendant Bones", default=True) + weight_threshold: bpy.props.FloatProperty(name="Weight Threshold", default=0.001, min=0.0, max=1.0, precision=4, subtype="FACTOR") + boundary_joint_owner: bpy.props.EnumProperty( + name="Boundary Joint Owner", + items=[ + ("SOURCE", "Source Model", ""), + ("DESTINATION", "Destination Model", ""), + ], + default="DESTINATION", + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object + + if context.mode != "POSE": + return False + + if active_object is None: + return False + + if active_object.type != "ARMATURE": + return False + + if FnModel.find_root_object(active_object) is None: + return False + + return len(context.selected_pose_bones) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + try: + self.separate(context) + except MessageException as ex: + self.report(type={"ERROR"}, message=str(ex)) + return {"CANCELLED"} + + return {"FINISHED"} + + def separate(self, context: bpy.types.Context): + weight_threshold: float = self.weight_threshold + mmd_scale = 0.08 + + target_armature_object: bpy.types.Object = context.active_object + + bpy.ops.object.mode_set(mode="EDIT") + root_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + + if self.include_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} + + mmd_root_object: bpy.types.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(self.select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys()) + + # separate armature bones + separate_armature_object: Optional[bpy.types.Object] + if self.separate_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) + bpy.ops.object.mode_set(mode="OBJECT") + + # collect separate rigid bodies + separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones} + + boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all + + # collect separate joints + separate_joints: Set[bpy.types.Object] = { + joint_object + for joint_object in mmd_model.joints() + if boundary_joint_owner_condition( + [ + joint_object.rigid_body_constraint.object1 in separate_rigid_bodies, + joint_object.rigid_body_constraint.object2 in separate_rigid_bodies, + ] + ) + } + + separate_mesh_objects: Set[bpy.types.Object] + model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] + if len(mmd_model_mesh_objects) == 0: + separate_mesh_objects = set() + model2separate_mesh_objects = dict() + else: + # select meshes + obj: bpy.types.Object + for obj in context.view_layer.objects: + obj.select_set(obj in mmd_model_mesh_objects) + context.view_layer.objects.active = mmd_model_mesh_objects[0] + + # separate mesh by selected vertices + 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] + bpy.ops.object.mode_set(mode="OBJECT") + + model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects)) + + 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() + + if self.separate_armature: + with context.temp_override( + active_object=separate_model_armature_object, + selected_editable_objects=[separate_model_armature_object, separate_armature_object], + ): + bpy.ops.object.join() + + # add mesh + with context.temp_override( + object=separate_model_armature_object, + selected_editable_objects=[separate_model_armature_object, *separate_mesh_objects], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + # replace mesh armature modifier.object + for separate_mesh in separate_mesh_objects: + armature_modifier: Optional[bpy.types.ArmatureModifier] = next(iter([m for m in separate_mesh.modifiers if m.type == "ARMATURE"]), None) + if armature_modifier is None: + armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") + + armature_modifier.object = separate_model_armature_object + + 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) + + with context.temp_override( + object=separate_model.jointGroupObject(), + selected_editable_objects=[separate_model.jointGroupObject(), *separate_joints], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + # move separate objects to new collection + mmd_layer_collection = FnContext.find_user_layer_collection_by_object(context, mmd_root_object) + assert mmd_layer_collection is not None + + separate_layer_collection = FnContext.find_user_layer_collection_by_object(context, separate_root_object) + assert separate_layer_collection is not None + + if mmd_layer_collection.name != 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) + + FnModel.copy_mmd_root( + separate_root_object, + mmd_root_object, + overwrite=True, + replace_name2values={ + # replace related_mesh property values + "related_mesh": {m.data.name: s.data.name for m, s in model2separate_mesh_objects.items()} + }, + ) + + 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() + 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 + target_bmesh.from_mesh(mesh, face_normals=False) + target_bmesh.select_mode |= {"VERT"} + deform_layer = target_bmesh.verts.layers.deform.verify() + + selected_vertex_count = 0 + vert: bmesh.types.BMVert + for vert in target_bmesh.verts: + vert.select_set(False) + + # Find the largest weight vertex group + weights = [(group_index, weight) for group_index, weight in vert[deform_layer].items() if vertex_groups[group_index].name in deform_bones] + + weights.sort(key=lambda i: vertex_groups[i[0]].name in separate_bones, reverse=True) + weights.sort(key=itemgetter(1), reverse=True) + group_index, weight = next(iter(weights), (0, -1)) + + if weight < weight_threshold: + continue + + if vertex_groups[group_index].name not in separate_bones: + continue + + selected_vertex_count += 1 + vert.select_set(True) + + if selected_vertex_count > 0: + mesh2selected_vertex_count[mesh_object] = selected_vertex_count + target_bmesh.select_flush_mode() + target_bmesh.to_mesh(mesh) + + target_bmesh.clear() + + return mesh2selected_vertex_count diff --git a/core/mmd/operators/morph.py b/core/mmd/operators/morph.py new file mode 100644 index 0000000..1b34420 --- /dev/null +++ b/core/mmd/operators/morph.py @@ -0,0 +1,776 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Optional, cast + +import bpy +from mathutils import Quaternion, Vector + +from ..core.model import FnModel +from .. import bpyutils, utils +from ..core.exceptions import MaterialNotFoundError +from ..core.material import FnMaterial +from ..core.morph import FnMorph +from ..utils import ItemMoveOp, ItemOp + + +# Util functions +def divide_vector_components(vec1, vec2): + if len(vec1) != len(vec2): + raise ValueError("Vectors should have the same number of components") + result = [] + for v1, v2 in zip(vec1, vec2): + if v2 == 0: + if v1 == 0: + v2 = 1 # If we have a 0/0 case we change the divisor to 1 + else: + raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero") + result.append(v1 / v2) + return result + + +def multiply_vector_components(vec1, vec2): + if len(vec1) != len(vec2): + raise ValueError("Vectors should have the same number of components") + result = [] + for v1, v2 in zip(vec1, vec2): + result.append(v1 * v2) + return result + + +def special_division(n1, n2): + """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: + n2 = 1 + else: + raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero") + return n1 / n2 + + +class AddMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_add" + bl_label = "Add Morph" + bl_description = "Add a morph item to active morph list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morphs = getattr(mmd_root, morph_type) + morph, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph) + morph.name = "New Morph" + if morph_type.startswith("uv"): + morph.data_type = "VERTEX_GROUP" + return {"FINISHED"} + + +class RemoveMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_remove" + bl_label = "Remove Morph" + bl_description = "Remove morph item(s) from the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + all: bpy.props.BoolProperty( + name="All", + description="Delete all morph items", + default=False, + options={"SKIP_SAVE"}, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + + morph_type = mmd_root.active_morph_type + if morph_type.startswith("material"): + bpy.ops.mmd_tools.clear_temp_materials() + elif morph_type.startswith("uv"): + bpy.ops.mmd_tools.clear_uv_morph_view() + + morphs = getattr(mmd_root, morph_type) + if self.all: + morphs.clear() + mmd_root.active_morph = 0 + else: + morphs.remove(mmd_root.active_morph) + mmd_root.active_morph = max(0, mmd_root.active_morph - 1) + return {"FINISHED"} + + +class MoveMorph(bpy.types.Operator, ItemMoveOp): + bl_idname = "mmd_tools.morph_move" + bl_label = "Move Morph" + bl_description = "Move active morph item up/down in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + mmd_root.active_morph = self.move( + getattr(mmd_root, mmd_root.active_morph_type), + mmd_root.active_morph, + self.type, + ) + return {"FINISHED"} + + +class CopyMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_copy" + bl_label = "Copy Morph" + bl_description = "Make a copy of active morph in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + + morph_type = mmd_root.active_morph_type + morphs = getattr(mmd_root, morph_type) + morph = ItemOp.get_by_index(morphs, mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer()) + + if morph_type.startswith("vertex"): + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.copy_shape_key(obj, name_orig, name_tmp) + + elif morph_type.startswith("uv"): + if morph.data_type == "VERTEX_GROUP": + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.copy_uv_morph_vertex_groups(obj, name_orig, name_tmp) + + morph_new, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph) + 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 + return {"FINISHED"} + + +class OverwriteBoneMorphsFromActionPose(bpy.types.Operator): + bl_idname = "mmd_tools.morph_overwrite_from_active_action_pose" + bl_label = "Overwrite Bone Morphs from active Action Pose" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + root = FnModel.find_root_object(context.active_object) + FnMorph.overwrite_bone_morphs_from_action_pose(FnModel.find_armature_object(root)) + + return {"FINISHED"} + + +class AddMorphOffset(bpy.types.Operator): + bl_idname = "mmd_tools.morph_offset_add" + bl_label = "Add Morph Offset" + bl_description = "Add a morph offset item to the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + item, morph.active_data = ItemOp.add_after(morph.data, morph.active_data) + + if morph_type.startswith("material"): + if obj.type == "MESH" and obj.mmd_type == "NONE": + item.related_mesh = obj.data.name + active_material = obj.active_material + if active_material and "_temp" not in active_material.name: + item.material = active_material.name + + elif morph_type.startswith("bone"): + pose_bone = context.active_pose_bone + if pose_bone: + item.bone = pose_bone.name + item.location = pose_bone.location + item.rotation = pose_bone.rotation_quaternion + + return {"FINISHED"} + + +class RemoveMorphOffset(bpy.types.Operator): + bl_idname = "mmd_tools.morph_offset_remove" + bl_label = "Remove Morph Offset" + bl_description = "Remove morph offset item(s) from the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + all: bpy.props.BoolProperty( + name="All", + description="Delete all morph offset items", + default=False, + options={"SKIP_SAVE"}, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + if morph_type.startswith("material"): + bpy.ops.mmd_tools.clear_temp_materials() + + if self.all: + if morph_type.startswith("vertex"): + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.remove_shape_key(obj, 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) + return {"FINISHED"} + morph.data.clear() + morph.active_data = 0 + else: + morph.data.remove(morph.active_data) + morph.active_data = max(0, morph.active_data - 1) + return {"FINISHED"} + + +class InitMaterialOffset(bpy.types.Operator): + bl_idname = "mmd_tools.material_morph_offset_init" + bl_label = "Init Material Offset" + bl_description = "Set all offset values to target value" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + target_value: bpy.props.FloatProperty( + name="Target Value", + description="Target value", + default=0, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + val = self.target_value + mat_data.diffuse_color = mat_data.edge_color = (val,) * 4 + 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 + return {"FINISHED"} + + +class ApplyMaterialOffset(bpy.types.Operator): + bl_idname = "mmd_tools.apply_material_morph_offset" + bl_label = "Apply Material Offset" + bl_description = "Calculates the offsets and apply them, then the temporary material is removed" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + if not mat_data.related_mesh: + self.report({"ERROR"}, "You need to choose a Related Mesh first") + return {"CANCELLED"} + meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh) + if meshObj is None: + self.report({"ERROR"}, "The model mesh can't be found") + return {"CANCELLED"} + try: + work_mat_name = mat_data.material + "_temp" + work_mat, base_mat = FnMaterial.swap_materials(meshObj, work_mat_name, mat_data.material) + except MaterialNotFoundError: + self.report({"ERROR"}, "Material not found") + return {"CANCELLED"} + + base_mmd_mat = base_mat.mmd_material + work_mmd_mat = work_mat.mmd_material + + if mat_data.offset_type == "MULT": + try: + diffuse_offset = divide_vector_components(work_mmd_mat.diffuse_color, base_mmd_mat.diffuse_color) + [special_division(work_mmd_mat.alpha, base_mmd_mat.alpha)] + specular_offset = divide_vector_components(work_mmd_mat.specular_color, base_mmd_mat.specular_color) + edge_offset = divide_vector_components(work_mmd_mat.edge_color, base_mmd_mat.edge_color) + mat_data.diffuse_color = diffuse_offset + mat_data.specular_color = specular_offset + mat_data.shininess = special_division(work_mmd_mat.shininess, base_mmd_mat.shininess) + mat_data.ambient_color = divide_vector_components(work_mmd_mat.ambient_color, base_mmd_mat.ambient_color) + mat_data.edge_color = edge_offset + mat_data.edge_weight = special_division(work_mmd_mat.edge_weight, base_mmd_mat.edge_weight) + + except ZeroDivisionError: + mat_data.offset_type = "ADD" # If there is any 0 division we automatically switch it to type ADD + except ValueError: + self.report({"ERROR"}, "An unexpected error happened") + # We should stop on our tracks and re-raise the exception + raise + + if mat_data.offset_type == "ADD": + diffuse_offset = list(work_mmd_mat.diffuse_color - base_mmd_mat.diffuse_color) + [work_mmd_mat.alpha - base_mmd_mat.alpha] + specular_offset = list(work_mmd_mat.specular_color - base_mmd_mat.specular_color) + edge_offset = Vector(work_mmd_mat.edge_color) - Vector(base_mmd_mat.edge_color) + mat_data.diffuse_color = diffuse_offset + mat_data.specular_color = specular_offset + mat_data.shininess = work_mmd_mat.shininess - base_mmd_mat.shininess + mat_data.ambient_color = work_mmd_mat.ambient_color - base_mmd_mat.ambient_color + mat_data.edge_color = list(edge_offset) + 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) + return {"FINISHED"} + + +class CreateWorkMaterial(bpy.types.Operator): + bl_idname = "mmd_tools.create_work_material" + bl_label = "Create Work Material" + bl_description = "Creates a temporary material to edit this offset" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + if not mat_data.related_mesh: + self.report({"ERROR"}, "You need to choose a Related Mesh first") + return {"CANCELLED"} + meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh) + if meshObj is None: + self.report({"ERROR"}, "The model mesh can't be found") + return {"CANCELLED"} + + base_mat = meshObj.data.materials.get(mat_data.material, None) + if base_mat is None: + self.report({"ERROR"}, 'Material "%s" not found' % mat_data.material) + return {"CANCELLED"} + + work_mat_name = base_mat.name + "_temp" + if work_mat_name in bpy.data.materials: + self.report({"ERROR"}, 'Temporary material "%s" is in use' % work_mat_name) + return {"CANCELLED"} + + work_mat = base_mat.copy() + work_mat.name = work_mat_name + meshObj.data.materials.append(work_mat) + FnMaterial.swap_materials(meshObj, base_mat.name, work_mat.name) + base_mmd_mat = base_mat.mmd_material + work_mmd_mat = work_mat.mmd_material + work_mmd_mat.material_id = -1 + + # Apply the offsets + if mat_data.offset_type == "MULT": + diffuse_offset = multiply_vector_components(base_mmd_mat.diffuse_color, mat_data.diffuse_color[0:3]) + specular_offset = multiply_vector_components(base_mmd_mat.specular_color, mat_data.specular_color) + edge_offset = multiply_vector_components(base_mmd_mat.edge_color, mat_data.edge_color) + ambient_offset = multiply_vector_components(base_mmd_mat.ambient_color, mat_data.ambient_color) + work_mmd_mat.diffuse_color = diffuse_offset + work_mmd_mat.alpha *= mat_data.diffuse_color[3] + work_mmd_mat.specular_color = specular_offset + work_mmd_mat.shininess *= mat_data.shininess + work_mmd_mat.ambient_color = ambient_offset + work_mmd_mat.edge_color = edge_offset + work_mmd_mat.edge_weight *= mat_data.edge_weight + elif mat_data.offset_type == "ADD": + diffuse_offset = Vector(base_mmd_mat.diffuse_color) + Vector(mat_data.diffuse_color[0:3]) + specular_offset = Vector(base_mmd_mat.specular_color) + Vector(mat_data.specular_color) + edge_offset = Vector(base_mmd_mat.edge_color) + Vector(mat_data.edge_color) + ambient_offset = Vector(base_mmd_mat.ambient_color) + Vector(mat_data.ambient_color) + work_mmd_mat.diffuse_color = list(diffuse_offset) + work_mmd_mat.alpha += mat_data.diffuse_color[3] + work_mmd_mat.specular_color = list(specular_offset) + work_mmd_mat.shininess += mat_data.shininess + work_mmd_mat.ambient_color = list(ambient_offset) + work_mmd_mat.edge_color = list(edge_offset) + work_mmd_mat.edge_weight += mat_data.edge_weight + + return {"FINISHED"} + + +class ClearTempMaterials(bpy.types.Operator): + bl_idname = "mmd_tools.clear_temp_materials" + bl_label = "Clear Temp Materials" + bl_description = "Clears all the temporary materials" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + for meshObj in FnModel.iterate_mesh_objects(root): + + def __pre_remove(m): + if m and "_temp" in m.name: + base_mat_name = m.name.split("_temp")[0] + try: + FnMaterial.swap_materials(meshObj, m.name, base_mat_name) + return True + except MaterialNotFoundError: + self.report({"WARNING"}, "Base material for %s was not found" % m.name) + return False + + FnMaterial.clean_materials(meshObj, can_remove=__pre_remove) + return {"FINISHED"} + + +class ViewBoneMorph(bpy.types.Operator): + bl_idname = "mmd_tools.view_bone_morph" + bl_label = "View Bone Morph" + bl_description = "View the result of active bone morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + utils.selectSingleBone(context, armature, None, True) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + for morph_data in morph.data: + p_bone: Optional[bpy.types.PoseBone] = armature.pose.bones.get(morph_data.bone, None) + if p_bone: + p_bone.bone.select = True + mtx = (p_bone.matrix_basis.to_3x3() @ Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix()).to_4x4() + mtx.translation = p_bone.location + morph_data.location + p_bone.matrix_basis = mtx + return {"FINISHED"} + + +class ClearBoneMorphView(bpy.types.Operator): + bl_idname = "mmd_tools.clear_bone_morph_view" + bl_label = "Clear Bone Morph View" + bl_description = "Reset transforms of all bones to their default values" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + armature = FnModel.find_armature_object(root) + for p_bone in armature.pose.bones: + p_bone.matrix_basis.identity() + return {"FINISHED"} + + +class ApplyBoneMorph(bpy.types.Operator): + bl_idname = "mmd_tools.apply_bone_morph" + bl_label = "Apply Bone Morph" + bl_description = "Apply current pose to active bone morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + armature = FnModel.find_armature_object(root) + mmd_root = root.mmd_root + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph.data.clear() + morph.active_data = 0 + for p_bone in armature.pose.bones: + if p_bone.location.length > 0 or p_bone.matrix_basis.decompose()[1].angle > 0: + item = morph.data.add() + item.bone = p_bone.name + item.location = p_bone.location + item.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion() + p_bone.bone.select = True + else: + p_bone.bone.select = False + return {"FINISHED"} + + +class SelectRelatedBone(bpy.types.Operator): + bl_idname = "mmd_tools.select_bone_morph_offset_bone" + bl_label = "Select Related Bone" + bl_description = "Select the bone assigned to this offset in the armature" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + utils.selectSingleBone(context, armature, morph_data.bone) + return {"FINISHED"} + + +class EditBoneOffset(bpy.types.Operator): + bl_idname = "mmd_tools.edit_bone_morph_offset" + bl_label = "Edit Related Bone" + bl_description = "Applies the location and rotation of this offset to the bone" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + p_bone = armature.pose.bones[morph_data.bone] + mtx = Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix().to_4x4() + mtx.translation = morph_data.location + p_bone.matrix_basis = mtx + utils.selectSingleBone(context, armature, p_bone.name) + return {"FINISHED"} + + +class ApplyBoneOffset(bpy.types.Operator): + bl_idname = "mmd_tools.apply_bone_morph_offset" + bl_label = "Apply Bone Morph Offset" + bl_description = "Stores the current bone location and rotation into this offset" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + assert armature is not None + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + 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() + return {"FINISHED"} + + +class ViewUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.view_uv_morph" + bl_label = "View UV Morph" + bl_description = "View the result of active UV morph on current mesh object" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + + meshes = tuple(FnModel.iterate_mesh_objects(root)) + if len(meshes) == 1: + obj = meshes[0] + elif obj not in meshes: + self.report({"ERROR"}, "Please select a mesh object") + return {"CANCELLED"} + meshObj = obj + + bpy.ops.mmd_tools.clear_uv_morph_view() + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + morph = mmd_root.uv_morphs[mmd_root.active_morph] + uv_textures = mesh.uv_layers + + base_uv_layers = [l for l in mesh.uv_layers if not l.name.startswith("_")] + if morph.uv_index >= len(base_uv_layers): + self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index) + return {"CANCELLED"} + + uv_layer_name = base_uv_layers[morph.uv_index].name + if morph.uv_index == 0 or uv_textures.active.name not in {uv_layer_name, "_" + uv_layer_name}: + uv_textures.active = uv_textures[uv_layer_name] + + uv_layer_name = uv_textures.active.name + uv_tex = uv_textures.new(name="__uv.%s" % uv_layer_name) + if uv_tex is None: + self.report({"ERROR"}, "Failed to create a temporary uv layer") + return {"CANCELLED"} + + offsets = FnMorph.get_uv_morph_offset_map(meshObj, morph).items() + offsets = {k: getattr(Vector(v), "zw" if uv_layer_name.startswith("_") else "xy") for k, v in offsets} + if len(offsets) > 0: + base_uv_data = mesh.uv_layers.active.data + temp_uv_data = mesh.uv_layers[uv_tex.name].data + for i, l in enumerate(mesh.loops): + select = temp_uv_data[i].select = l.vertex_index in offsets + if select: + temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index] + + uv_textures.active = uv_tex + uv_tex.active_render = True + meshObj.hide_set(False) + meshObj.select_set(selected) + return {"FINISHED"} + + +class ClearUVMorphView(bpy.types.Operator): + bl_idname = "mmd_tools.clear_uv_morph_view" + bl_label = "Clear UV Morph View" + bl_description = "Clear all temporary data of UV morphs" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + for m in FnModel.iterate_mesh_objects(root): + mesh = m.data + uv_textures = getattr(mesh, "uv_textures", mesh.uv_layers) + for t in uv_textures: + if t.name.startswith("__uv."): + uv_textures.remove(t) + if len(uv_textures) > 0: + uv_textures[0].active_render = True + uv_textures.active_index = 0 + + animation_data = mesh.animation_data + if animation_data: + nla_tracks = animation_data.nla_tracks + for t in nla_tracks: + if t.name.startswith("__uv."): + nla_tracks.remove(t) + if animation_data.action and animation_data.action.name.startswith("__uv."): + animation_data.action = None + if animation_data.action is None and len(nla_tracks) == 0: + mesh.animation_data_clear() + + for act in bpy.data.actions: + if act.name.startswith("__uv.") and act.users < 1: + bpy.data.actions.remove(act) + return {"FINISHED"} + + +class EditUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.edit_uv_morph" + bl_label = "Edit UV Morph" + bl_description = "Edit UV morph on a temporary UV layer (use UV Editor to edit the result)" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + obj = context.active_object + meshObj = obj + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_mode(type="VERT", action="ENABLE") + bpy.ops.mesh.reveal() # unhide all vertices + bpy.ops.mesh.select_all(action="DESELECT") + bpy.ops.object.mode_set(mode="OBJECT") + + vertices = mesh.vertices + for l, d in zip(mesh.loops, mesh.uv_layers.active.data): + if d.select: + vertices[l.vertex_index].select = True + + polygons = mesh.polygons + polygons.active = getattr(next((p for p in polygons if all(vertices[i].select for i in p.vertices)), None), "index", polygons.active) + + bpy.ops.object.mode_set(mode="EDIT") + meshObj.select_set(selected) + return {"FINISHED"} + + +class ApplyUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.apply_uv_morph" + bl_label = "Apply UV Morph" + bl_description = "Calculate the UV offsets of selected vertices and apply to active UV morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + meshObj = obj + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + morph = mmd_root.uv_morphs[mmd_root.active_morph] + + base_uv_name = mesh.uv_layers.active.name[5:] + if base_uv_name not in mesh.uv_layers: + self.report({"ERROR"}, ' * UV map "%s" not found' % base_uv_name) + return {"CANCELLED"} + + base_uv_data = mesh.uv_layers[base_uv_name].data + temp_uv_data = mesh.uv_layers.active.data + axis_type = "ZW" if base_uv_name.startswith("_") else "XY" + + from collections import namedtuple + + __OffsetData = namedtuple("OffsetData", "index, offset") + offsets = {} + vertices = mesh.vertices + for l, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data): + if vertices[l.vertex_index].select and l.vertex_index not in offsets: + dx, dy = i1.uv - i0.uv + if abs(dx) > 0.0001 or abs(dy) > 0.0001: + offsets[l.vertex_index] = __OffsetData(l.vertex_index, (dx, dy, dx, dy)) + + FnMorph.store_uv_morph_data(meshObj, morph, offsets.values(), axis_type) + morph.data_type = "VERTEX_GROUP" + + meshObj.select_set(selected) + return {"FINISHED"} + + +class CleanDuplicatedMaterialMorphs(bpy.types.Operator): + bl_idname = "mmd_tools.clean_duplicated_material_morphs" + bl_label = "Clean Duplicated Material Morphs" + bl_description = "Clean duplicated material morphs" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.active_object) is not None + + def execute(self, context: bpy.types.Context): + mmd_root_object = FnModel.find_root_object(context.active_object) + FnMorph.clean_duplicated_material_morphs(mmd_root_object) + + return {"FINISHED"} diff --git a/core/mmd/operators/rigid_body.py b/core/mmd/operators/rigid_body.py new file mode 100644 index 0000000..22e3515 --- /dev/null +++ b/core/mmd/operators/rigid_body.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import math +from typing import Dict, Optional, Tuple, cast + +import bpy +from mathutils import Euler, Vector + +from .. import utils +from ..bpyutils import FnContext, Props +from ..core import rigid_body +from ..core.model import FnModel, Model +from ..core.rigid_body import FnRigidBody + + +class SelectRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_select" + bl_label = "Select Rigid Body" + bl_description = "Select similar rigidbody objects which have the same property values with active rigidbody object" + bl_options = {"REGISTER", "UNDO"} + + properties: bpy.props.EnumProperty( + name="Properties", + description="Select the properties to be compared", + options={"ENUM_FLAG"}, + items=[ + ("collision_group_number", "Collision Group", "Collision group", 1), + ("collision_group_mask", "Collision Group Mask", "Collision group mask", 2), + ("type", "Rigid Type", "Rigid type", 4), + ("shape", "Shape", "Collision shape", 8), + ("bone", "Bone", "Target bone", 16), + ], + default=set(), + ) + hide_others: bpy.props.BoolProperty( + name="Hide Others", + description="Hide the rigidbody object which does not have the same property values with active rigidbody object", + default=False, + ) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + @classmethod + def poll(cls, context): + return FnModel.is_rigid_body_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.report({"ERROR"}, "The model root can't be found") + return {"CANCELLED"} + + selection = set(FnModel.iterate_rigid_body_objects(root)) + + for prop_name in self.properties: + prop_value = getattr(obj.mmd_rigid, prop_name) + if prop_name == "collision_group_mask": + prop_value = tuple(prop_value) + for i in selection.copy(): + if tuple(i.mmd_rigid.collision_group_mask) != prop_value: + selection.remove(i) + if self.hide_others: + i.select_set(False) + i.hide_set(True) + else: + for i in selection.copy(): + if getattr(i.mmd_rigid, prop_name) != prop_value: + selection.remove(i) + if self.hide_others: + i.select_set(False) + i.hide_set(True) + + for i in selection: + i.hide_set(False) + i.select_set(True) + + return {"FINISHED"} + + +class AddRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_add" + bl_label = "Add Rigid Body" + bl_description = "Add Rigid Bodies to selected bones" + bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"} + + name_j: bpy.props.StringProperty( + name="Name", + description="The name of rigid body ($name_j means use the japanese name of target bone)", + default="$name_j", + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="The english name of rigid body ($name_e means use the english name of target bone)", + default="$name_e", + ) + collision_group_number: bpy.props.IntProperty( + name="Collision Group", + description="The collision group of the object", + min=0, + max=15, + ) + collision_group_mask: bpy.props.BoolVectorProperty( + name="Collision Group Mask", + description="The groups the object can not collide with", + size=16, + subtype="LAYER", + ) + rigid_type: bpy.props.EnumProperty( + name="Rigid Type", + description="Select rigid type", + items=[ + (str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1), + (str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2), + (str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3), + ], + ) + rigid_shape: bpy.props.EnumProperty( + name="Shape", + description="Select the collision shape", + items=[ + ("SPHERE", "Sphere", "", 1), + ("BOX", "Box", "", 2), + ("CAPSULE", "Capsule", "", 3), + ], + ) + size: bpy.props.FloatVectorProperty( + name="Size", + description="Size of the object, the values will multiply the length of target bone", + subtype="XYZ", + size=3, + min=0, + default=[0.6, 0.6, 0.6], + ) + mass: bpy.props.FloatProperty( + name="Mass", + description="How much the object 'weights' irrespective of gravity", + min=0.001, + default=1, + ) + friction: bpy.props.FloatProperty( + name="Friction", + description="Resistance of object to movement", + min=0, + soft_max=1, + default=0.5, + ) + bounce: bpy.props.FloatProperty( + name="Restitution", + description="Tendency of object to bounce after colliding with another (0 = stays still, 1 = perfectly elastic)", + min=0, + soft_max=1, + ) + linear_damping: bpy.props.FloatProperty( + name="Linear Damping", + description="Amount of linear velocity that is lost over time", + min=0, + max=1, + default=0.04, + ) + angular_damping: bpy.props.FloatProperty( + name="Angular Damping", + description="Amount of angular velocity that is lost over time", + min=0, + max=1, + default=0.1, + ) + + def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None): + name_j: str = self.name_j + name_e: str = self.name_e + size = self.size.copy() + loc = Vector((0.0, 0.0, 0.0)) + rot = Euler((0.0, 0.0, 0.0)) + bone_name: Optional[str] = None + + if pose_bone is None: + size *= getattr(root_object, Props.empty_display_size) + else: + bone_name = pose_bone.name + mmd_bone = pose_bone.mmd_bone + name_j = name_j.replace("$name_j", mmd_bone.name_j or bone_name) + name_e = name_e.replace("$name_e", mmd_bone.name_e or bone_name) + + target_bone = pose_bone.bone + loc = (target_bone.head_local + target_bone.tail_local) / 2 + rot = target_bone.matrix_local.to_euler("YXZ") + rot.rotate_axis("X", math.pi / 2) + + size *= target_bone.length + if 1: + pass # bypass resizing + elif self.rigid_shape == "SPHERE": + size.x *= 0.8 + elif self.rigid_shape == "BOX": + size.x /= 3 + size.y /= 3 + size.z *= 0.8 + elif self.rigid_shape == "CAPSULE": + size.x /= 3 + + return FnRigidBody.setup_rigid_body_object( + obj=FnRigidBody.new_rigid_body_object(context, FnModel.ensure_rigid_group_object(context, root_object)), + shape_type=rigid_body.shapeType(self.rigid_shape), + location=loc, + rotation=rot, + size=size, + dynamics_type=int(self.rigid_type), + name=name_j, + name_e=name_e, + collision_group_number=self.collision_group_number, + collision_group_mask=self.collision_group_mask, + mass=self.mass, + friction=self.friction, + bounce=self.bounce, + linear_damping=self.linear_damping, + angular_damping=self.angular_damping, + bone=bone_name, + ) + + @classmethod + def poll(cls, context): + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + return False + + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return False + + return True + + def execute(self, context): + active_object = context.active_object + + root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) + armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) + + if active_object != armature_object: + FnContext.select_single_object(context, root_object).select_set(False) + elif armature_object.mode != "POSE": + bpy.ops.object.mode_set(mode="POSE") + + selected_pose_bones = [] + if context.selected_pose_bones: + selected_pose_bones = context.selected_pose_bones + + armature_object.select_set(False) + if len(selected_pose_bones) > 0: + for pose_bone in selected_pose_bones: + rigid = self.__add_rigid_body(context, root_object, pose_bone) + rigid.select_set(True) + else: + rigid = self.__add_rigid_body(context, root_object) + rigid.select_set(True) + return {"FINISHED"} + + def invoke(self, context, event): + no_bone = True + if context.selected_bones and len(context.selected_bones) > 0: + no_bone = False + elif context.selected_pose_bones and len(context.selected_pose_bones) > 0: + no_bone = False + + if no_bone: + self.name_j = "Rigid" + self.name_e = "Rigid_e" + else: + if self.name_j == "Rigid": + self.name_j = "$name_j" + if self.name_e == "Rigid_e": + self.name_e = "$name_e" + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class RemoveRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_remove" + bl_label = "Remove Rigid Body" + bl_description = "Deletes the currently selected Rigid Body" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.is_rigid_body_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + utils.selectAObject(obj) # ensure this is the only one object select + bpy.ops.object.delete(use_global=True) + if root: + utils.selectAObject(root) + return {"FINISHED"} + + +class RigidBodyBake(bpy.types.Operator): + bl_idname = "mmd_tools.ptcache_rigid_body_bake" + bl_label = "Bake" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context: bpy.types.Context): + with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): + bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True) + + return {"FINISHED"} + + +class RigidBodyDeleteBake(bpy.types.Operator): + bl_idname = "mmd_tools.ptcache_rigid_body_delete_bake" + bl_label = "Delete Bake" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context: bpy.types.Context): + with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): + bpy.ops.ptcache.free_bake("INVOKE_DEFAULT") + + return {"FINISHED"} + + +class AddJoint(bpy.types.Operator): + bl_idname = "mmd_tools.joint_add" + bl_label = "Add Joint" + bl_description = "Add Joint(s) to selected rigidbody objects" + bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"} + + use_bone_rotation: bpy.props.BoolProperty( + name="Use Bone Rotation", + description="Match joint orientation to bone orientation if enabled", + default=True, + ) + limit_linear_lower: bpy.props.FloatVectorProperty( + name="Limit Linear Lower", + description="Lower limit of translation", + subtype="XYZ", + size=3, + ) + limit_linear_upper: bpy.props.FloatVectorProperty( + name="Limit Linear Upper", + description="Upper limit of translation", + subtype="XYZ", + size=3, + ) + limit_angular_lower: bpy.props.FloatVectorProperty( + name="Limit Angular Lower", + description="Lower limit of rotation", + subtype="EULER", + size=3, + min=-math.pi * 2, + max=math.pi * 2, + default=[-math.pi / 4] * 3, + ) + limit_angular_upper: bpy.props.FloatVectorProperty( + name="Limit Angular Upper", + description="Upper limit of rotation", + subtype="EULER", + size=3, + min=-math.pi * 2, + max=math.pi * 2, + default=[math.pi / 4] * 3, + ) + spring_linear: bpy.props.FloatVectorProperty( + name="Spring(Linear)", + description="Spring constant of movement", + subtype="XYZ", + size=3, + min=0, + ) + spring_angular: bpy.props.FloatVectorProperty( + name="Spring(Angular)", + description="Spring constant of rotation", + subtype="XYZ", + size=3, + min=0, + ) + + def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]): + obj_seq = tuple(bone_map.keys()) + for rigid_a, bone_a in bone_map.items(): + for rigid_b, bone_b in bone_map.items(): + if bone_a and bone_b and bone_b.parent == bone_a: + obj_seq = () + yield (rigid_a, rigid_b) + if len(obj_seq) == 2: + if obj_seq[1].mmd_rigid.type == str(rigid_body.MODE_STATIC): + yield (obj_seq[1], obj_seq[0]) + 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): + loc: Optional[Vector] = None + rot = Euler((0.0, 0.0, 0.0)) + rigid_a, rigid_b = rigid_pair + bone_a = bone_map[rigid_a] + bone_b = bone_map[rigid_b] + if bone_a and bone_b: + if bone_a.parent == bone_b: + rigid_b, rigid_a = rigid_a, rigid_b + bone_b, bone_a = bone_a, bone_b + if bone_b.parent == bone_a: + loc = bone_b.head_local + if self.use_bone_rotation: + rot = bone_b.matrix_local.to_euler("YXZ") + rot.rotate_axis("X", math.pi / 2) + if loc is None: + loc = (rigid_a.location + rigid_b.location) / 2 + + name_j = rigid_b.mmd_rigid.name_j or rigid_b.name + name_e = rigid_b.mmd_rigid.name_e or rigid_b.name + + return FnRigidBody.setup_joint_object( + obj=FnRigidBody.new_joint_object(context, FnModel.ensure_joint_group_object(context, root_object), FnModel.get_empty_display_size(root_object)), + name=name_j, + name_e=name_e, + location=loc, + rotation=rot, + rigid_a=rigid_a, + rigid_b=rigid_b, + maximum_location=self.limit_linear_upper, + minimum_location=self.limit_linear_lower, + maximum_rotation=self.limit_angular_upper, + minimum_rotation=self.limit_angular_lower, + spring_linear=self.spring_linear, + spring_angular=self.spring_angular, + ) + + @classmethod + def poll(cls, context): + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + return False + + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return False + + return True + + def execute(self, context): + active_object = context.active_object + root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) + armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) + bones = cast(bpy.types.Armature, armature_object.data).bones + bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]] = {r: bones.get(r.mmd_rigid.bone, None) for r in FnModel.iterate_rigid_body_objects(root_object) if r.select_get()} + + if len(bone_map) < 2: + self.report({"ERROR"}, "Please select two or more mmd rigid objects") + return {"CANCELLED"} + + FnContext.select_single_object(context, root_object).select_set(False) + if context.scene.rigidbody_world is None: + bpy.ops.rigidbody.world_add() + + for pair in self.__enumerate_rigid_pair(bone_map): + joint = self.__add_joint(context, root_object, pair, bone_map) + joint.select_set(True) + + return {"FINISHED"} + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class RemoveJoint(bpy.types.Operator): + bl_idname = "mmd_tools.joint_remove" + bl_label = "Remove Joint" + bl_description = "Deletes the currently selected Joint" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.is_joint_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + utils.selectAObject(obj) # ensure this is the only one object select + bpy.ops.object.delete(use_global=True) + if root: + utils.selectAObject(root) + return {"FINISHED"} + + +class UpdateRigidBodyWorld(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_world_update" + bl_label = "Update Rigid Body World" + bl_description = "Update rigid body world and references of rigid body constraint according to current scene objects (experimental)" + bl_options = {"REGISTER", "UNDO"} + + @staticmethod + def __get_rigid_body_world_objects(): + rigid_body.setRigidBodyWorldEnabled(True) + rbw = bpy.context.scene.rigidbody_world + if not rbw.collection: + rbw.collection = bpy.data.collections.new("RigidBodyWorld") + rbw.collection.use_fake_user = True + if not rbw.constraints: + rbw.constraints = bpy.data.collections.new("RigidBodyConstraints") + rbw.constraints.use_fake_user = True + + bpy.context.scene.rigidbody_world.substeps_per_frame = 6 + bpy.context.scene.rigidbody_world.solver_iterations = 10 + + return rbw.collection.objects, rbw.constraints.objects + + def execute(self, context): + scene = context.scene + scene_objs = set(scene.objects) + scene_objs.union(o for x in scene.objects if x.instance_type == "COLLECTION" and x.instance_collection for o in x.instance_collection.objects) + + def _update_group(obj, group): + if obj in scene_objs: + if obj not in group.values(): + group.link(obj) + return True + elif obj in group.values(): + group.unlink(obj) + return False + + def _references(obj): + yield obj + if getattr(obj, "proxy", None): + yield from _references(obj.proxy) + if getattr(obj, "override_library", None): + yield from _references(obj.override_library.reference) + + need_rebuild_physics = scene.rigidbody_world is None or scene.rigidbody_world.collection is None or scene.rigidbody_world.constraints is None + rb_objs, rbc_objs = self.__get_rigid_body_world_objects() + objects = bpy.data.objects + table = {} + + # Perhaps due to a bug in Blender, + # when bpy.ops.rigidbody.world_remove(), + # Object.rigid_body are removed, + # but Object.rigid_body_constraint are retained. + # Therefore, it must be checked with Object.mmd_type. + for i in (x for x in objects if x.mmd_type == "RIGID_BODY"): + if not _update_group(i, rb_objs): + continue + + rb_map = table.setdefault(FnModel.find_root_object(i), {}) + if i in rb_map: # means rb_map[i] will replace i + rb_objs.unlink(i) + continue + for r in _references(i): + rb_map[r] = i + + # TODO Modify mmd_rigid to allow recovery of the remaining rigidbody parameters. + # mass, friction, restitution, linear_dumping, angular_dumping + + for i in (x for x in objects if x.rigid_body_constraint): + if not _update_group(i, rbc_objs): + continue + + rbc, root_object = i.rigid_body_constraint, FnModel.find_root_object(i) + rb_map = table.get(root_object, {}) + rbc.object1 = rb_map.get(rbc.object1, rbc.object1) + rbc.object2 = rb_map.get(rbc.object2, rbc.object2) + + if need_rebuild_physics: + for root_object in scene.objects: + if root_object.mmd_type != "ROOT": + continue + if not root_object.mmd_root.is_built: + continue + with FnContext.temp_override_active_layer_collection(context, root_object): + Model(root_object).build() + # After rebuild. First play. Will be crash! + # But saved it before. Reload after crash. The play can be work. + + return {"FINISHED"} diff --git a/core/mmd/operators/sdef.py b/core/mmd/operators/sdef.py new file mode 100644 index 0000000..e38badd --- /dev/null +++ b/core/mmd/operators/sdef.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Set + +import bpy +from bpy.types import Operator + +from ..core.model import FnModel +from ..core.sdef import FnSDEF + + +def _get_target_objects(context): + root_objects: Set[bpy.types.Object] = set() + selected_objects: Set[bpy.types.Object] = set() + for i in context.selected_objects: + if i.type == "MESH": + selected_objects.add(i) + continue + + root_object = FnModel.find_root_object(i) + if root_object is None: + continue + if root_object in root_objects: + continue + + root_objects.add(root_object) + + selected_objects |= set(FnModel.iterate_mesh_objects(root_object)) + return selected_objects, root_objects + + +class ResetSDEFCache(Operator): + bl_idname = "mmd_tools.sdef_cache_reset" + bl_label = "Reset MMD SDEF cache" + bl_description = "Reset MMD SDEF cache of selected objects and clean unused cache" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + target_meshes, _ = _get_target_objects(context) + for i in target_meshes: + FnSDEF.clear_cache(i) + FnSDEF.clear_cache(unused_only=True) + return {"FINISHED"} + + +class BindSDEF(Operator): + bl_idname = "mmd_tools.sdef_bind" + bl_label = "Bind SDEF Driver" + bl_description = "Bind MMD SDEF data of selected objects" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + mode: bpy.props.EnumProperty( + name="Mode", + description="Select mode", + items=[ + ("2", "Bulk", "Speed up with numpy (may be slower in some cases)", 2), + ("1", "Normal", "Normal mode", 1), + ("0", "- Auto -", "Select best mode by benchmark result", 0), + ], + default="0", + ) + use_skip: bpy.props.BoolProperty( + name="Skip", + description="Skip when the bones are not moving", + default=True, + ) + use_scale: bpy.props.BoolProperty( + name="Scale", + description="Support bone scaling (slow)", + default=False, + ) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + # TODO: Utility Functionalize + def execute(self, context): + target_meshes, root_objects = _get_target_objects(context) + + 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) + self.report({"INFO"}, f"Binded {count} of {len(target_meshes)} selected mesh(es)") + return {"FINISHED"} + + +class UnbindSDEF(Operator): + bl_idname = "mmd_tools.sdef_unbind" + bl_label = "Unbind SDEF Driver" + bl_description = "Unbind MMD SDEF data of selected objects" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + # TODO: Utility Functionalize + def execute(self, context): + target_meshes, root_objects = _get_target_objects(context) + for i in target_meshes: + FnSDEF.unbind(i) + + for r in root_objects: + r.mmd_root.use_sdef = False + + return {"FINISHED"} diff --git a/core/mmd/operators/translations.py b/core/mmd/operators/translations.py new file mode 100644 index 0000000..371427c --- /dev/null +++ b/core/mmd/operators/translations.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import TYPE_CHECKING, cast + +import bpy + +from ..core.model import FnModel, Model +from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations +from ..translations import DictionaryEnum + +if TYPE_CHECKING: + from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex + + +class TranslateMMDModel(bpy.types.Operator): + bl_idname = "mmd_tools.translate_mmd_model" + bl_label = "Translate a MMD Model" + bl_description = "Translate Japanese names of a MMD model" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + dictionary: bpy.props.EnumProperty( + name="Dictionary", + items=DictionaryEnum.get_dictionary_items, + description="Translate names from Japanese to English using selected dictionary", + ) + types: bpy.props.EnumProperty( + name="Types", + description="Select which parts will be translated", + options={"ENUM_FLAG"}, + items=[ + ("BONE", "Bones", "Bones", 1), + ("MORPH", "Morphs", "Morphs", 2), + ("MATERIAL", "Materials", "Materials", 4), + ("DISPLAY", "Display", "Display frames", 8), + ("PHYSICS", "Physics", "Rigidbodies and joints", 16), + ("INFO", "Information", "Model name and comments", 32), + ], + default={ + "BONE", + "MORPH", + "MATERIAL", + "DISPLAY", + "PHYSICS", + }, + ) + modes: bpy.props.EnumProperty( + name="Modes", + description="Select translation mode", + options={"ENUM_FLAG"}, + items=[ + ("MMD", "MMD Names", "Fill MMD English names", 1), + ("BLENDER", "Blender Names", "Translate blender names (experimental)", 2), + ], + default={"MMD"}, + ) + use_morph_prefix: bpy.props.BoolProperty( + name="Use Morph Prefix", + description="Add/remove prefix to English name of morph", + default=False, + ) + overwrite: bpy.props.BoolProperty( + name="Overwrite", + description="Overwrite a translated English name", + default=False, + ) + allow_fails: bpy.props.BoolProperty( + name="Allow Fails", + description="Allow incompletely translated names", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj in context.selected_objects and FnModel.find_root_object(obj) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + try: + self.__translator = DictionaryEnum.get_translator(self.dictionary) + except Exception as e: + self.report({"ERROR"}, "Failed to load dictionary: %s" % e) + return {"CANCELLED"} + + obj = context.active_object + root = FnModel.find_root_object(obj) + rig = Model(root) + + if "MMD" in self.modes: + for i in self.types: + getattr(self, "translate_%s" % i.lower())(rig) + + if "BLENDER" in self.modes: + self.translate_blender_names(rig) + + translator = self.__translator + txt = translator.save_fails() + if translator.fails: + self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(translator.fails), txt.name)) + return {"FINISHED"} + + def translate(self, name_j, name_e): + if not self.overwrite and name_e and self.__translator.is_translated(name_e): + return name_e + if self.allow_fails: + name_e = None + return self.__translator.translate(name_j, name_e) + + def translate_blender_names(self, rig: Model): + if "BONE" in self.types: + for b in rig.armature().pose.bones: + rig.renameBone(b.name, self.translate(b.name, b.name)) + + if "MORPH" in self.types: + for i in (x for x in rig.meshes() if x.data.shape_keys): + for kb in i.data.shape_keys.key_blocks: + kb.name = self.translate(kb.name, kb.name) + + if "MATERIAL" in self.types: + for m in (x for x in rig.materials() if x): + m.name = self.translate(m.name, m.name) + + if "DISPLAY" in self.types: + g: bpy.types.BoneCollection + for g in cast(bpy.types.Armature, rig.armature().data).collections: + g.name = self.translate(g.name, g.name) + + if "PHYSICS" in self.types: + for i in rig.rigidBodies(): + i.name = self.translate(i.name, i.name) + + for i in rig.joints(): + i.name = self.translate(i.name, i.name) + + if "INFO" in self.types: + objects = [rig.rootObject(), rig.armature()] + objects.extend(rig.meshes()) + for i in objects: + i.name = self.translate(i.name, i.name) + + def translate_info(self, rig): + mmd_root = rig.rootObject().mmd_root + mmd_root.name_e = self.translate(mmd_root.name, mmd_root.name_e) + + comment_text = bpy.data.texts.get(mmd_root.comment_text, None) + comment_e_text = bpy.data.texts.get(mmd_root.comment_e_text, None) + if comment_text and comment_e_text: + comment_e = self.translate(comment_text.as_string(), comment_e_text.as_string()) + comment_e_text.from_string(comment_e) + + def translate_bone(self, rig): + bones = rig.armature().pose.bones + for b in bones: + if b.is_mmd_shadow_bone: + continue + b.mmd_bone.name_e = self.translate(b.mmd_bone.name_j, b.mmd_bone.name_e) + + def translate_morph(self, rig): + mmd_root = rig.rootObject().mmd_root + attr_list = ("group", "vertex", "bone", "uv", "material") + prefix_list = ("G_", "", "B_", "UV_", "M_") + for attr, prefix in zip(attr_list, prefix_list): + for m in getattr(mmd_root, attr + "_morphs", []): + m.name_e = self.translate(m.name, m.name_e) + if not prefix: + continue + if self.use_morph_prefix: + if not m.name_e.startswith(prefix): + m.name_e = prefix + m.name_e + elif m.name_e.startswith(prefix): + m.name_e = m.name_e[len(prefix) :] + + def translate_material(self, rig): + for m in rig.materials(): + if m is None: + continue + m.mmd_material.name_e = self.translate(m.mmd_material.name_j, m.mmd_material.name_e) + + def translate_display(self, rig): + mmd_root = rig.rootObject().mmd_root + for f in mmd_root.display_item_frames: + f.name_e = self.translate(f.name, f.name_e) + + def translate_physics(self, rig): + for i in rig.rigidBodies(): + i.mmd_rigid.name_e = self.translate(i.mmd_rigid.name_j, i.mmd_rigid.name_e) + + for i in rig.joints(): + i.mmd_joint.name_e = self.translate(i.mmd_joint.name_j, i.mmd_joint.name_e) + + +DEFAULT_SHOW_ROW_COUNT = 20 + + +class MMD_TOOLS_UL_MMDTranslationElementIndex(bpy.types.UIList): + def draw_item(self, context, layout: bpy.types.UILayout, data, mmd_translation_element_index: "MMDTranslationElementIndex", icon, active_data, active_propname, index: int): + mmd_translation_element: "MMDTranslationElement" = data.translation_elements[mmd_translation_element_index.value] + MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(layout, mmd_translation_element, index) + + +class RestoreMMDDataReferenceOperator(bpy.types.Operator): + bl_idname = "mmd_tools.restore_mmd_translation_element_name" + bl_label = "Restore this Name" + bl_options = {"INTERNAL"} + + index: bpy.props.IntProperty() + prop_name: bpy.props.StringProperty() + restore_value: bpy.props.StringProperty() + + def execute(self, context: bpy.types.Context): + root_object = FnModel.find_root_object(context.object) + mmd_translation_element_index = root_object.mmd_root.translation.filtered_translation_element_indices[self.index].value + mmd_translation_element = root_object.mmd_root.translation.translation_elements[mmd_translation_element_index] + setattr(mmd_translation_element, self.prop_name, self.restore_value) + + return {"FINISHED"} + + +class GlobalTranslationPopup(bpy.types.Operator): + bl_idname = "mmd_tools.global_translation_popup" + bl_label = "Global Translation Popup" + bl_options = {"INTERNAL", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.object) is not None + + def draw(self, _context): + layout = self.layout + mmd_translation = self._mmd_translation + + col = layout.column(align=True) + col.label(text="Filter", icon="FILTER") + row = col.row() + row.prop(mmd_translation, "filter_types") + + group = row.row(align=True, heading="is Blank:") + group.alignment = "RIGHT" + group.prop(mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese") + group.prop(mmd_translation, "filter_english_blank", toggle=True, text="English") + + group = row.row(align=True) + group.prop(mmd_translation, "filter_restorable", toggle=True, icon="FILE_REFRESH", icon_only=True) + group.prop(mmd_translation, "filter_selected", toggle=True, icon="RESTRICT_SELECT_OFF", icon_only=True) + group.prop(mmd_translation, "filter_visible", toggle=True, icon="HIDE_OFF", icon_only=True) + + col = layout.column(align=True) + box = col.box().column(align=True) + row = box.row(align=True) + row.label(text="Select the target column for Batch Operations:", icon="TRACKER") + row = box.row(align=True) + row.label(text="", icon="BLANK1") + row.prop(mmd_translation, "batch_operation_target", expand=True) + row.label(text="", icon="RESTRICT_SELECT_OFF") + row.label(text="", icon="HIDE_OFF") + + if len(mmd_translation.filtered_translation_element_indices) > DEFAULT_SHOW_ROW_COUNT: + row.label(text="", icon="BLANK1") + + col.template_list( + "MMD_TOOLS_UL_MMDTranslationElementIndex", + "", + mmd_translation, + "filtered_translation_element_indices", + mmd_translation, + "filtered_translation_element_indices_active_index", + rows=DEFAULT_SHOW_ROW_COUNT, + ) + + box = layout.box().column(align=True) + box.label(text="Batch Operation:", icon="MODIFIER") + box.prop(mmd_translation, "batch_operation_script", text="", icon="SCRIPT") + + box.separator() + row = box.row() + row.prop(mmd_translation, "batch_operation_script_preset", text="Preset", icon="CON_TRANSFORM_CACHE") + row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute") + + box.separator() + translation_box = box.box().column(align=True) + translation_box.label(text="Dictionaries:", icon="HELP") + row = translation_box.row() + row.prop(mmd_translation, "dictionary", text="to_english") + # row.operator(ExecuteTranslationScriptOperator.bl_idname, text='Write to .csv') + + translation_box.separator() + row = translation_box.row() + row.prop(mmd_translation, "dictionary", text="replace") + + def invoke(self, context: bpy.types.Context, _event): + root_object = FnModel.find_root_object(context.object) + if root_object is None: + return {"CANCELLED"} + + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + self._mmd_translation = mmd_translation + FnTranslations.clear_data(mmd_translation) + FnTranslations.collect_data(mmd_translation) + FnTranslations.update_query(mmd_translation) + + return context.window_manager.invoke_props_dialog(self, width=800) + + def execute(self, context): + root_object = FnModel.find_root_object(context.object) + if root_object is None: + return {"CANCELLED"} + + FnTranslations.apply_translations(root_object) + FnTranslations.clear_data(root_object.mmd_root.translation) + + return {"FINISHED"} + + +class ExecuteTranslationBatchOperator(bpy.types.Operator): + bl_idname = "mmd_tools.execute_translation_batch" + bl_label = "Execute Translation Batch" + bl_options = {"INTERNAL"} + + def execute(self, context: bpy.types.Context): + root = FnModel.find_root_object(context.object) + if root is None: + return {"CANCELLED"} + + fails, text = FnTranslations.execute_translation_batch(root) + if fails: + self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(fails), text.name)) + + return {"FINISHED"} diff --git a/core/mmd/operators/view.py b/core/mmd/operators/view.py new file mode 100644 index 0000000..0072312 --- /dev/null +++ b/core/mmd/operators/view.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import re + +from bpy.types import Operator +from mathutils import Matrix + + +class _SetShadingBase: + bl_options = {"REGISTER", "UNDO"} + + @staticmethod + def _get_view3d_spaces(context): + if getattr(context.area, "type", None) == "VIEW_3D": + return (context.area.spaces[0],) + return (area.spaces[0] for area in getattr(context.screen, "areas", ()) if area.type == "VIEW_3D") + + @staticmethod + def _reset_color_management(context, use_display_device=True): + try: + context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device] + except TypeError: + pass + + @staticmethod + def _reset_material_shading(context, use_shadeless=False): + for i in (x for x in context.scene.objects if x.type == "MESH" and x.mmd_type == "NONE"): + for s in i.material_slots: + if s.material is None: + continue + s.material.use_nodes = False + s.material.use_shadeless = use_shadeless + + def execute(self, context): + context.scene.render.engine = "BLENDER_EEVEE_NEXT" + + shading_mode = getattr(self, "_shading_mode", None) + for space in self._get_view3d_spaces(context): + shading = space.shading + shading.type = "SOLID" + shading.light = "FLAT" if shading_mode == "SHADELESS" else "STUDIO" + shading.color_type = "TEXTURE" if shading_mode else "MATERIAL" + shading.show_object_outline = False + shading.show_backface_culling = False + 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" + + _shading_mode = "GLSL" + + +class SetShadelessGLSLShading(Operator, _SetShadingBase): + bl_idname = "mmd_tools.set_shadeless_glsl_shading" + bl_label = "Shadeless GLSL View" + bl_description = "Use only toon shading" + + _shading_mode = "SHADELESS" + + +class ResetShading(Operator, _SetShadingBase): + bl_idname = "mmd_tools.reset_shading" + bl_label = "Reset View" + bl_description = "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"} + + # https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html + __LR_REGEX = [ + {"re": re.compile(r"^(.+)(RIGHT|LEFT)(\.\d+)?$", re.IGNORECASE), "lr": 1}, + {"re": re.compile(r"^(.+)([\.\- _])(L|R)(\.\d+)?$", re.IGNORECASE), "lr": 2}, + {"re": re.compile(r"^(LEFT|RIGHT)(.+)$", re.IGNORECASE), "lr": 0}, + {"re": re.compile(r"^(L|R)([\.\- _])(.+)$", re.IGNORECASE), "lr": 0}, + {"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1}, + {"re": re.compile(r"^(左|右)(.+)$"), "lr": 0}, + ] + __LR_MAP = { + "RIGHT": "LEFT", + "Right": "Left", + "right": "left", + "LEFT": "RIGHT", + "Left": "Right", + "left": "right", + "L": "R", + "l": "r", + "R": "L", + "r": "l", + "左": "右", + "右": "左", + } + + @classmethod + def flip_name(cls, name): + for regex in cls.__LR_REGEX: + match = regex["re"].match(name) + if match: + groups = match.groups() + lr = groups[regex["lr"]] + if lr in cls.__LR_MAP: + flip_lr = cls.__LR_MAP[lr] + name = "" + for i, s in enumerate(groups): + if i == regex["lr"]: + name += flip_lr + elif s: + name += s + return name + return "" + + @staticmethod + def __cmul(vec1, vec2): + return type(vec1)([x * y for x, y in zip(vec1, vec2)]) + + @staticmethod + def __matrix_compose(loc, rot, scale): + return (Matrix.Translation(loc) @ rot.to_matrix().to_4x4()) @ Matrix([(scale[0], 0, 0, 0), (0, scale[1], 0, 0), (0, 0, scale[2], 0), (0, 0, 0, 1)]) + + @classmethod + def __flip_pose(cls, matrix_basis, bone_src, bone_dest): + from mathutils import Quaternion + + 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() + loc = cls.__cmul(mi @ loc, (-1, 1, 1)) + rot = cls.__cmul(Quaternion(mi @ rot.axis, rot.angle).normalized(), (1, 1, -1, -1)) + bone_dest.matrix_basis = cls.__matrix_compose(m @ loc, Quaternion(m @ rot.axis, rot.angle).normalized(), scale) + + @classmethod + def poll(cls, context): + return context.active_object and context.active_object.type == "ARMATURE" and context.active_object.mode == "POSE" + + def execute(self, context): + pose_bones = context.active_object.pose.bones + for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]: + self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b)) + return {"FINISHED"} diff --git a/core/mmd/properties/__init__.py b/core/mmd/properties/__init__.py index e69de29..9f5926d 100644 --- a/core/mmd/properties/__init__.py +++ b/core/mmd/properties/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + + +def patch_library_overridable(property: "bpy.props._PropertyDeferred") -> "bpy.props._PropertyDeferred": + """Apply recursively for each mmd_tools property class annotations. + Args: + property: The property to be patched. + + Returns: + The patched property. + """ + property.keywords.setdefault("override", set()).add("LIBRARY_OVERRIDABLE") + + if property.function.__name__ not in {"PointerProperty", "CollectionProperty"}: + return property + + property_type = property.keywords["type"] + # The __annotations__ cannot be inherited. Manually search for base classes. + for inherited_type in (property_type, *property_type.__bases__): + if not inherited_type.__module__.startswith("mmd_tools.properties"): + continue + for annotation in inherited_type.__annotations__.values(): + if not isinstance(annotation, bpy.props._PropertyDeferred): + continue + patch_library_overridable(annotation) + + return property diff --git a/core/mmd/properties/material.py b/core/mmd/properties/material.py new file mode 100644 index 0000000..d3df3a3 --- /dev/null +++ b/core/mmd/properties/material.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from .. import utils +from ..core import material +from ..core.material import FnMaterial +from ..core.model import FnModel +from . import patch_library_overridable + + +def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_ambient_color() + + +def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_diffuse_color() + + +def _mmd_material_update_alpha(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_alpha() + + +def _mmd_material_update_specular_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_specular_color() + + +def _mmd_material_update_shininess(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_shininess() + + +def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_is_double_sided() + + +def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context): + FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object) + + +def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_toon_texture() + + +def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_drop_shadow() + + +def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_self_shadow_map() + + +def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_self_shadow() + + +def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_enabled_toon_edge() + + +def _mmd_material_update_edge_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_edge_color() + + +def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_edge_weight() + + +def _mmd_material_get_name_j(prop: "MMDMaterial"): + return prop.get("name_j", "") + + +def _mmd_material_set_name_j(prop: "MMDMaterial", value: str): + prop_value = value + if prop_value and prop_value != prop.get("name_j"): + root = FnModel.find_root_object(bpy.context.active_object) + if root is None: + prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in bpy.data.materials}) + else: + prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in FnModel.iterate_materials(root)}) + + prop["name_j"] = prop_value + + +# =========================================== +# Property classes +# =========================================== + + +class MMDMaterial(bpy.types.PropertyGroup): + """マテリアル""" + + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + set=_mmd_material_set_name_j, + get=_mmd_material_get_name_j, + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + material_id: bpy.props.IntProperty( + name="Material ID", + description="Unique ID for the reference of material morph", + default=-1, + min=-1, + ) + + ambient_color: bpy.props.FloatVectorProperty( + name="Ambient Color", + description="Ambient color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.4, 0.4, 0.4], + update=_mmd_material_update_ambient_color, + ) + + diffuse_color: bpy.props.FloatVectorProperty( + name="Diffuse Color", + description="Diffuse color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.8, 0.8, 0.8], + update=_mmd_material_update_diffuse_color, + ) + + alpha: bpy.props.FloatProperty( + name="Alpha", + description="Alpha transparency", + min=0, + max=1, + precision=3, + step=0.1, + default=1.0, + update=_mmd_material_update_alpha, + ) + + specular_color: bpy.props.FloatVectorProperty( + name="Specular Color", + description="Specular color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.625, 0.625, 0.625], + update=_mmd_material_update_specular_color, + ) + + shininess: bpy.props.FloatProperty( + name="Reflect", + description="Sharpness of reflected highlights", + min=0, + soft_max=512, + step=100.0, + default=50.0, + update=_mmd_material_update_shininess, + ) + + is_double_sided: bpy.props.BoolProperty( + name="Double Sided", + description="Both sides of mesh should be rendered", + default=False, + update=_mmd_material_update_is_double_sided, + ) + + enabled_drop_shadow: bpy.props.BoolProperty( + name="Ground Shadow", + description="Display ground shadow", + default=True, + update=_mmd_material_update_enabled_drop_shadow, + ) + + enabled_self_shadow_map: bpy.props.BoolProperty( + name="Self Shadow Map", + description="Object can become shadowed by other objects", + default=True, + update=_mmd_material_update_enabled_self_shadow_map, + ) + + enabled_self_shadow: bpy.props.BoolProperty( + name="Self Shadow", + description="Object can cast shadows", + default=True, + update=_mmd_material_update_enabled_self_shadow, + ) + + enabled_toon_edge: bpy.props.BoolProperty( + name="Toon Edge", + description="Use toon edge", + default=False, + update=_mmd_material_update_enabled_toon_edge, + ) + + edge_color: bpy.props.FloatVectorProperty( + name="Edge Color", + description="Toon edge color", + subtype="COLOR", + size=4, + min=0, + max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_mmd_material_update_edge_color, + ) + + edge_weight: bpy.props.FloatProperty( + name="Edge Weight", + description="Toon edge size", + min=0, + max=100, + soft_max=2, + step=1.0, + default=1.0, + update=_mmd_material_update_edge_weight, + ) + + sphere_texture_type: bpy.props.EnumProperty( + name="Sphere Map Type", + description="Choose sphere texture blend type", + items=[ + (str(material.SPHERE_MODE_OFF), "Off", "", 1), + (str(material.SPHERE_MODE_MULT), "Multiply", "", 2), + (str(material.SPHERE_MODE_ADD), "Add", "", 3), + (str(material.SPHERE_MODE_SUBTEX), "SubTexture", "", 4), + ], + update=_mmd_material_update_sphere_texture_type, + ) + + is_shared_toon_texture: bpy.props.BoolProperty( + name="Use Shared Toon Texture", + description="Use shared toon texture or custom toon texture", + default=False, + update=_mmd_material_update_toon_texture, + ) + + toon_texture: bpy.props.StringProperty( + name="Toon Texture", + subtype="FILE_PATH", + description="The file path of custom toon texture", + default="", + update=_mmd_material_update_toon_texture, + ) + + shared_toon_texture: bpy.props.IntProperty( + name="Shared Toon Texture", + description="Shared toon texture id (toon01.bmp ~ toon10.bmp)", + default=0, + min=0, + max=9, + update=_mmd_material_update_toon_texture, + ) + + comment: bpy.props.StringProperty( + name="Comment", + description="Comment", + ) + + def is_id_unique(self): + return self.material_id < 0 or not next((m for m in bpy.data.materials if m.mmd_material != self and m.mmd_material.material_id == self.material_id), None) + + @staticmethod + def register(): + bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial)) + + @staticmethod + def unregister(): + del bpy.types.Material.mmd_material diff --git a/core/mmd/properties/morph.py b/core/mmd/properties/morph.py new file mode 100644 index 0000000..ba94350 --- /dev/null +++ b/core/mmd/properties/morph.py @@ -0,0 +1,488 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from .. import utils +from ..core.bone import FnBone +from ..core.material import FnMaterial +from ..core.model import FnModel, Model +from ..core.morph import FnMorph + + +def _morph_base_get_name(prop: "_MorphBase") -> str: + return prop.get("name", "") + + +def _morph_base_set_name(prop: "_MorphBase", value: str): + 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} + value = utils.unique_name(value, used_names) + if prop_name is not None: + if morph_type == "vertex_morphs": + kb_list = {} + for mesh in FnModel.iterate_mesh_objects(prop.id_data): + for kb in getattr(mesh.data.shape_keys, "key_blocks", ()): + kb_list.setdefault(kb.name, []).append(kb) + + if prop_name in kb_list: + value = utils.unique_name(value, used_names | kb_list.keys()) + for kb in kb_list[prop_name]: + kb.name = value + + elif morph_type == "uv_morphs": + vg_list = {} + for mesh in FnModel.iterate_mesh_objects(prop.id_data): + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(mesh): + vg_list.setdefault(n, []).append(vg) + + if prop_name in vg_list: + value = utils.unique_name(value, used_names | vg_list.keys()) + for vg in vg_list[prop_name]: + vg.name = vg.name.replace(prop_name, value) + + if 1: # morph_type != 'group_morphs': + for m in mmd_root.group_morphs: + for d in m.data: + if d.name == prop_name and d.morph_type == morph_type: + d.name = value + + frame_facial = mmd_root.display_item_frames.get("表情") + for item in getattr(frame_facial, "data", []): + if item.name == prop_name and item.morph_type == morph_type: + item.name = value + break + + obj = Model(prop.id_data).morph_slider.placeholder() + if obj and value not in obj.data.shape_keys.key_blocks: + kb = obj.data.shape_keys.key_blocks.get(prop_name, None) + if kb: + kb.name = value + + prop["name"] = value + + +class _MorphBase: + name: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + set=_morph_base_set_name, + get=_morph_base_get_name, + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + category: bpy.props.EnumProperty( + name="Category", + description="Select category", + items=[ + ("SYSTEM", "Hidden", "", 0), + ("EYEBROW", "Eye Brow", "", 1), + ("EYE", "Eye", "", 2), + ("MOUTH", "Mouth", "", 3), + ("OTHER", "Other", "", 4), + ], + default="OTHER", + ) + + +def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: + bone_id = prop.get("bone_id", -1) + if bone_id < 0: + return "" + root_object = prop.id_data + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return "" + pose_bone = FnBone.find_pose_bone_by_bone_id(armature_object, bone_id) + if pose_bone is None: + return "" + return pose_bone.name + + +def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str): + root = prop.id_data + arm = FnModel.find_armature_object(root) + + # Load the library_override file. This function is triggered when loading, but the arm obj cannot be found. + # The arm obj is exist, but the relative relationship has not yet been established. + if arm is None: + return + + if value not in arm.pose.bones.keys(): + prop["bone_id"] = -1 + return + pose_bone = arm.pose.bones[value] + prop["bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) + + +def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context): + if not prop.name.startswith("mmd_bind"): + return + arm = FnModel(prop.id_data).morph_slider.dummy_armature + if arm: + bone = arm.pose.bones.get(prop.name, None) + if bone: + bone.location = prop.location + bone.rotation_quaternion = prop.rotation.__class__(*prop.rotation.to_axis_angle()) # Fix for consistency + + +class BoneMorphData(bpy.types.PropertyGroup): + """ """ + + bone: bpy.props.StringProperty( + name="Bone", + description="Target bone", + set=_bone_morph_data_set_bone, + get=_bone_morph_data_get_bone, + ) + + bone_id: bpy.props.IntProperty( + name="Bone ID", + ) + + location: bpy.props.FloatVectorProperty( + name="Location", + description="Location", + subtype="TRANSLATION", + size=3, + default=[0, 0, 0], + update=_bone_morph_data_update_location_or_rotation, + ) + + rotation: bpy.props.FloatVectorProperty( + name="Rotation", + description="Rotation in quaternions", + subtype="QUATERNION", + size=4, + default=[1, 0, 0, 0], + update=_bone_morph_data_update_location_or_rotation, + ) + + +class BoneMorph(_MorphBase, bpy.types.PropertyGroup): + """Bone Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=BoneMorphData, + ) + active_data: bpy.props.IntProperty( + name="Active Bone Data", + min=0, + default=0, + ) + + +def _material_morph_data_get_material(prop: "MaterialMorphData"): + 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): + if value not in bpy.data.materials: + prop["material_data"] = None + prop["material_id"] = -1 + else: + mat = bpy.data.materials[value] + fnMat = FnMaterial(mat) + prop["material_data"] = mat + prop["material_id"] = fnMat.material_id + + +def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str): + mesh = FnModel.find_mesh_object_by_name(prop.id_data, value) + if mesh is not None: + prop["related_mesh_data"] = mesh.data + else: + prop["related_mesh_data"] = None + + +def _material_morph_data_get_related_mesh(prop): + 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): + if not prop.name.startswith("mmd_bind"): + return + from ..core.shader import _MaterialMorph + + mat = prop["material_data"] + if mat is not None: + _MaterialMorph.update_morph_inputs(mat, prop) + else: + for mat in FnModel(prop.id_data).materials(): + _MaterialMorph.update_morph_inputs(mat, prop) + + +class MaterialMorphData(bpy.types.PropertyGroup): + """ """ + + related_mesh: bpy.props.StringProperty( + name="Related Mesh", + description="Stores a reference to the mesh where this morph data belongs to", + set=_material_morph_data_set_related_mesh, + get=_material_morph_data_get_related_mesh, + ) + + related_mesh_data: bpy.props.PointerProperty( + name="Related Mesh Data", + type=bpy.types.Mesh, + ) + + offset_type: bpy.props.EnumProperty(name="Offset Type", description="Select offset type", items=[("MULT", "Multiply", "", 0), ("ADD", "Add", "", 1)], default="ADD") + + material: bpy.props.StringProperty( + name="Material", + description="Target material", + get=_material_morph_data_get_material, + set=_material_morph_data_set_material, + ) + + material_id: bpy.props.IntProperty( + name="Material ID", + default=-1, + ) + + material_data: bpy.props.PointerProperty( + name="Material Data", + type=bpy.types.Material, + ) + + diffuse_color: bpy.props.FloatVectorProperty( + name="Diffuse Color", + description="Diffuse color", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + specular_color: bpy.props.FloatVectorProperty( + name="Specular Color", + description="Specular color", + subtype="COLOR", + size=3, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0], + update=_material_morph_data_update_modifiable_values, + ) + + shininess: bpy.props.FloatProperty( + name="Reflect", + description="Reflect", + soft_min=0, + soft_max=500, + step=100.0, + default=0.0, + update=_material_morph_data_update_modifiable_values, + ) + + ambient_color: bpy.props.FloatVectorProperty( + name="Ambient Color", + description="Ambient color", + subtype="COLOR", + size=3, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0], + update=_material_morph_data_update_modifiable_values, + ) + + edge_color: bpy.props.FloatVectorProperty( + name="Edge Color", + description="Edge color", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + edge_weight: bpy.props.FloatProperty( + name="Edge Weight", + description="Edge weight", + soft_min=0, + soft_max=2, + step=0.1, + default=0, + update=_material_morph_data_update_modifiable_values, + ) + + texture_factor: bpy.props.FloatVectorProperty( + name="Texture factor", + description="Texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + sphere_texture_factor: bpy.props.FloatVectorProperty( + name="Sphere Texture factor", + description="Sphere texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + toon_texture_factor: bpy.props.FloatVectorProperty( + name="Toon Texture factor", + description="Toon texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + +class MaterialMorph(_MorphBase, bpy.types.PropertyGroup): + """Material Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=MaterialMorphData, + ) + active_data: bpy.props.IntProperty( + name="Active Material Data", + min=0, + default=0, + ) + + +class UVMorphOffset(bpy.types.PropertyGroup): + """UV Morph Offset""" + + index: bpy.props.IntProperty( + name="Vertex Index", + description="Vertex index", + min=0, + default=0, + ) + offset: bpy.props.FloatVectorProperty( + name="UV Offset", + description="UV offset", + size=4, + # min=-1, + # max=1, + # precision=3, + step=0.1, + default=[0, 0, 0, 0], + ) + + +class UVMorph(_MorphBase, bpy.types.PropertyGroup): + """UV Morph""" + + uv_index: bpy.props.IntProperty( + name="UV Index", + description="UV index (UV, UV1 ~ UV4)", + min=0, + max=4, + default=0, + ) + data_type: bpy.props.EnumProperty( + name="Data Type", + description="Select data type", + items=[ + ("DATA", "Data", "Store offset data in root object (deprecated)", 0), + ("VERTEX_GROUP", "Vertex Group", "Store offset data in vertex groups", 1), + ], + default="DATA", + ) + data: bpy.props.CollectionProperty( + name="Morph Data", + type=UVMorphOffset, + ) + active_data: bpy.props.IntProperty( + name="Active UV Data", + min=0, + default=0, + ) + vertex_group_scale: bpy.props.FloatProperty( + name="Vertex Group Scale", + description='The value scale of "Vertex Group" data type', + precision=3, + step=0.1, + default=1, + ) + + +class GroupMorphOffset(bpy.types.PropertyGroup): + """Group Morph Offset""" + + morph_type: bpy.props.EnumProperty( + name="Morph Type", + description="Select morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + factor: bpy.props.FloatProperty(name="Factor", description="Factor", soft_min=0, soft_max=1, precision=3, step=0.1, default=0) + + +class GroupMorph(_MorphBase, bpy.types.PropertyGroup): + """Group Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=GroupMorphOffset, + ) + active_data: bpy.props.IntProperty( + name="Active Group Data", + min=0, + default=0, + ) + + +class VertexMorph(_MorphBase, bpy.types.PropertyGroup): + """Vertex Morph""" diff --git a/core/mmd/properties/pose_bone.py b/core/mmd/properties/pose_bone.py index 7795325..3584c42 100644 --- a/core/mmd/properties/pose_bone.py +++ b/core/mmd/properties/pose_bone.py @@ -1,41 +1,33 @@ # -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +from typing import cast import bpy -from bpy.types import PropertyGroup, Context, PoseBone -from bpy.props import ( - StringProperty, - IntProperty, - BoolProperty, - FloatProperty, - FloatVectorProperty -) -from ..logging_setup import logger -from ..bone import FnBone +from ..core.bone import FnBone +from . import patch_library_overridable -def _mmd_bone_update_additional_transform(prop, context: Context): - """Update handler for additional transform properties""" + +def _mmd_bone_update_additional_transform(prop: "MMDBone", context: bpy.types.Context): 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(): FnBone.apply_additional_transformation(prop.id_data) -def _mmd_bone_update_additional_transform_influence(prop, context: Context): - """Update handler for additional transform influence""" + +def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: bpy.types.Context): pose_bone = context.active_pose_bone if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer(): FnBone.update_additional_transform_influence(pose_bone) else: prop["is_additional_transform_dirty"] = True -def _mmd_bone_get_additional_transform_bone(prop): - """Getter for additional transform bone property""" + +def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"): arm = prop.id_data bone_id = prop.get("additional_transform_bone_id", -1) if bone_id < 0: @@ -45,8 +37,8 @@ def _mmd_bone_get_additional_transform_bone(prop): return "" return pose_bone.name -def _mmd_bone_set_additional_transform_bone(prop, value: str): - """Setter for additional transform bone property""" + +def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str): arm = prop.id_data prop["is_additional_transform_dirty"] = True if value not in arm.pose.bones.keys(): @@ -55,85 +47,70 @@ def _mmd_bone_set_additional_transform_bone(prop, value: str): pose_bone = arm.pose.bones[value] prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) -def _pose_bone_update_mmd_ik_toggle(prop: PoseBone, _context): - """Update handler for IK toggle property""" - v = prop.mmd_ik_toggle - armature_object = prop.id_data - for b in armature_object.pose.bones: - for c in b.constraints: - if c.type == "IK" and c.subtarget == prop.name: - logger.debug('Updating IK constraint %s on bone %s', c.name, b.name) - c.influence = v - b_chain = b if c.use_tail else b.parent - for chain_bone in ([b_chain] + b_chain.parent_recursive)[:c.chain_count]: - limit_c = next((c for c in chain_bone.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) - if limit_c: - limit_c.influence = v -class MMDBone(PropertyGroup): - """Property group for MMD bone properties""" - name_j: StringProperty( +class MMDBone(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", default="", ) - - name_e: StringProperty( + + name_e: bpy.props.StringProperty( name="Name(Eng)", description="English Name", default="", ) - - bone_id: IntProperty( + + bone_id: bpy.props.IntProperty( name="Bone ID", description="Unique ID for the reference of bone morph and rotate+/move+", default=-1, min=-1, ) - - transform_order: IntProperty( + + transform_order: bpy.props.IntProperty( name="Transform Order", description="Deformation tier", min=0, max=100, soft_max=7, ) - - is_controllable: BoolProperty( + + is_controllable: bpy.props.BoolProperty( name="Controllable", description="Is controllable", default=True, ) - - transform_after_dynamics: BoolProperty( + + transform_after_dynamics: bpy.props.BoolProperty( name="After Dynamics", description="After physics", default=False, ) - - enabled_fixed_axis: BoolProperty( + + enabled_fixed_axis: bpy.props.BoolProperty( name="Fixed Axis", description="Use fixed axis", default=False, ) - - fixed_axis: FloatVectorProperty( + + fixed_axis: bpy.props.FloatVectorProperty( name="Fixed Axis", description="Fixed axis", subtype="XYZ", size=3, precision=3, - step=0.1, + step=0.1, # 0.1 / 100 default=[0, 0, 0], ) - - enabled_local_axes: BoolProperty( + + enabled_local_axes: bpy.props.BoolProperty( name="Local Axes", description="Use local axes", default=False, ) - - local_axis_x: FloatVectorProperty( + + local_axis_x: bpy.props.FloatVectorProperty( name="Local X-Axis", description="Local x-axis", subtype="XYZ", @@ -142,8 +119,8 @@ class MMDBone(PropertyGroup): step=0.1, default=[1, 0, 0], ) - - local_axis_z: FloatVectorProperty( + + local_axis_z: bpy.props.FloatVectorProperty( name="Local Z-Axis", description="Local z-axis", subtype="XYZ", @@ -152,14 +129,14 @@ class MMDBone(PropertyGroup): step=0.1, default=[0, 0, 1], ) - - is_tip: BoolProperty( + + is_tip: bpy.props.BoolProperty( name="Tip Bone", description="Is zero length bone", default=False, ) - - ik_rotation_constraint: FloatProperty( + + ik_rotation_constraint: bpy.props.FloatProperty( name="IK Rotation Constraint", description="The unit angle of IK", subtype="ANGLE", @@ -167,36 +144,36 @@ class MMDBone(PropertyGroup): soft_max=4, default=1, ) - - has_additional_rotation: BoolProperty( + + has_additional_rotation: bpy.props.BoolProperty( name="Additional Rotation", description="Additional rotation", default=False, update=_mmd_bone_update_additional_transform, ) - - has_additional_location: BoolProperty( + + has_additional_location: bpy.props.BoolProperty( name="Additional Location", description="Additional location", default=False, update=_mmd_bone_update_additional_transform, ) - - additional_transform_bone: StringProperty( + + additional_transform_bone: bpy.props.StringProperty( name="Additional Transform Bone", description="Additional transform bone", set=_mmd_bone_set_additional_transform_bone, get=_mmd_bone_get_additional_transform_bone, update=_mmd_bone_update_additional_transform, ) - - additional_transform_bone_id: IntProperty( + + additional_transform_bone_id: bpy.props.IntProperty( name="Additional Transform Bone ID", default=-1, update=_mmd_bone_update_additional_transform, ) - - additional_transform_influence: FloatProperty( + + additional_transform_influence: bpy.props.FloatProperty( name="Additional Transform Influence", description="Additional transform influence", default=1, @@ -204,47 +181,44 @@ class MMDBone(PropertyGroup): soft_max=1, update=_mmd_bone_update_additional_transform_influence, ) - - is_additional_transform_dirty: BoolProperty( - name="", - default=True - ) - + + is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True) + def is_id_unique(self): - """Check if the bone ID is unique""" 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(): + 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")) + bpy.types.PoseBone.mmd_ik_toggle = patch_library_overridable( + bpy.props.BoolProperty( + name="MMD IK Toggle", + description="MMD IK toggle is used to import/export animation of IK on-off", + update=_pose_bone_update_mmd_ik_toggle, + default=True, + ) + ) -def register(): - """Register MMD bone properties""" - logger.info("Registering MMD bone properties") - bpy.utils.register_class(MMDBone) - - # Add properties to PoseBone - bpy.types.PoseBone.mmd_bone = bpy.props.PointerProperty(type=MMDBone) - bpy.types.PoseBone.is_mmd_shadow_bone = bpy.props.BoolProperty( - name="is_mmd_shadow_bone", - default=False - ) - bpy.types.PoseBone.mmd_shadow_bone_type = bpy.props.StringProperty( - name="mmd_shadow_bone_type" - ) - bpy.types.PoseBone.mmd_ik_toggle = bpy.props.BoolProperty( - name="MMD IK Toggle", - description="MMD IK toggle is used to import/export animation of IK on-off", - update=_pose_bone_update_mmd_ik_toggle, - default=True, - ) + @staticmethod + def unregister(): + 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 unregister(): - """Unregister MMD bone properties""" - logger.info("Unregistering MMD bone properties") - - # Remove properties from PoseBone - 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 - - bpy.utils.unregister_class(MMDBone) \ No newline at end of file +def _pose_bone_update_mmd_ik_toggle(prop: bpy.types.PoseBone, _context): + v = prop.mmd_ik_toggle + armature_object = cast(bpy.types.Object, prop.id_data) + for b in armature_object.pose.bones: + for c in b.constraints: + if c.type == "IK" and c.subtarget == prop.name: + # logging.debug(' %s %s', b.name, c.name) + c.influence = v + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + c.influence = v diff --git a/core/mmd/properties/rigid_body.py b/core/mmd/properties/rigid_body.py new file mode 100644 index 0000000..3941657 --- /dev/null +++ b/core/mmd/properties/rigid_body.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +"""Properties for rigid bodies and joints""" + +import bpy + +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 + + +def _updateCollisionGroup(prop, _context): + obj = prop.id_data + materials = obj.data.materials + if len(materials) == 0: + materials.append(RigidBodyMaterial.getMaterial(prop.collision_group_number)) + else: + obj.material_slots[0].material = RigidBodyMaterial.getMaterial(prop.collision_group_number) + + +def _updateType(prop, _context): + obj = 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 + + if len(obj.data.vertices) > 0: + size = prop.size + prop.size = size # update mesh + + rb = obj.rigid_body + if rb: + rb.collision_shape = prop.shape + + +def _get_bone(prop): + obj = prop.id_data + relation = obj.constraints.get("mmd_tools_rigid_parent", None) + if relation: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name in arm.data.bones: + return bone_name + return prop.get("bone", "") + + +def _set_bone(prop, value): + bone_name = value + obj = prop.id_data + relation = obj.constraints.get("mmd_tools_rigid_parent", None) + if relation is None: + relation = obj.constraints.new("CHILD_OF") + relation.name = "mmd_tools_rigid_parent" + relation.mute = True + + arm = relation.target + if arm is None: + root = FnModel.find_root_object(obj) + if root: + arm = relation.target = FnModel.find_armature_object(root) + + if arm is not None and bone_name in arm.data.bones: + relation.subtarget = bone_name + else: + relation.subtarget = bone_name = "" + + prop["bone"] = bone_name + + +def _get_size(prop): + if prop.id_data.mmd_type != "RIGID_BODY": + return (0, 0, 0) + return FnRigidBody.get_rigid_body_size(prop.id_data) + + +def _set_size(prop, value): + obj = prop.id_data + assert obj.mode == "OBJECT" # not support other mode yet + shape = prop.shape + + mesh = obj.data + rb = obj.rigid_body + + if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape: + if shape == "SPHERE": + bpyutils.makeSphere( + radius=value[0], + target_object=obj, + ) + elif shape == "BOX": + bpyutils.makeBox( + size=value, + target_object=obj, + ) + elif shape == "CAPSULE": + bpyutils.makeCapsule( + radius=value[0], + height=value[1], + target_object=obj, + ) + mesh.update() + if rb: + rb.collision_shape = shape + else: + if shape == "SPHERE": + radius = max(value[0], 1e-3) + for v in mesh.vertices: + vec = v.co.normalized() + v.co = vec * radius + elif shape == "BOX": + x = max(value[0], 1e-3) + y = max(value[1], 1e-3) + z = max(value[2], 1e-3) + for v in mesh.vertices: + x0, y0, z0 = v.co + x0 = -x if x0 < 0 else x + y0 = -y if y0 < 0 else y + z0 = -z if z0 < 0 else z + v.co = [x0, y0, z0] + elif shape == "CAPSULE": + r0, h0, xx = FnRigidBody.get_rigid_body_size(prop.id_data) + h0 *= 0.5 + radius = max(value[0], 1e-3) + height = max(value[1], 1e-3) * 0.5 + scale = radius / max(r0, 1e-3) + for v in mesh.vertices: + x0, y0, z0 = v.co + x0 *= scale + y0 *= scale + if z0 < 0: + z0 = (z0 + h0) * scale - height + else: + z0 = (z0 - h0) * scale + height + v.co = [x0, y0, z0] + mesh.update() + + +def _get_rigid_name(prop): + return prop.get("name", "") + + +def _set_rigid_name(prop, value): + prop["name"] = value + + +class MMDRigidBody(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + get=_get_rigid_name, + set=_set_rigid_name, + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + collision_group_number: bpy.props.IntProperty( + name="Collision Group", + description="The collision group of the object", + min=0, + max=15, + default=1, + update=_updateCollisionGroup, + ) + + collision_group_mask: bpy.props.BoolVectorProperty( + name="Collision Group Mask", + description="The groups the object can not collide with", + size=16, + subtype="LAYER", + ) + + type: bpy.props.EnumProperty( + name="Rigid Type", + description="Select rigid type", + items=[ + (str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1), + (str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2), + (str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3), + ], + update=_updateType, + ) + + shape: bpy.props.EnumProperty( + name="Shape", + description="Select the collision shape", + items=[ + ("SPHERE", "Sphere", "", 1), + ("BOX", "Box", "", 2), + ("CAPSULE", "Capsule", "", 3), + ], + update=_updateShape, + ) + + bone: bpy.props.StringProperty( + name="Bone", + description="Target bone", + default="", + get=_get_bone, + set=_set_bone, + ) + + size: bpy.props.FloatVectorProperty( + name="Size", + description="Size of the object", + subtype="XYZ", + size=3, + min=0, + step=0.1, + get=_get_size, + set=_set_size, + ) + + @staticmethod + def register(): + bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody)) + + @staticmethod + def unregister(): + del bpy.types.Object.mmd_rigid + + +def _updateSpringLinear(prop, context): + obj = prop.id_data + rbc = obj.rigid_body_constraint + if rbc: + rbc.spring_stiffness_x = prop.spring_linear[0] + rbc.spring_stiffness_y = prop.spring_linear[1] + rbc.spring_stiffness_z = prop.spring_linear[2] + + +def _updateSpringAngular(prop, context): + obj = prop.id_data + rbc = obj.rigid_body_constraint + if rbc and hasattr(rbc, "use_spring_ang_x"): + rbc.spring_stiffness_ang_x = prop.spring_angular[0] + rbc.spring_stiffness_ang_y = prop.spring_angular[1] + rbc.spring_stiffness_ang_z = prop.spring_angular[2] + + +class MMDJoint(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + spring_linear: bpy.props.FloatVectorProperty( + name="Spring(Linear)", + description="Spring constant of movement", + subtype="XYZ", + size=3, + min=0, + step=0.1, + update=_updateSpringLinear, + ) + + spring_angular: bpy.props.FloatVectorProperty( + name="Spring(Angular)", + description="Spring constant of rotation", + subtype="XYZ", + size=3, + min=0, + step=0.1, + update=_updateSpringAngular, + ) + + @staticmethod + def register(): + bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint)) + + @staticmethod + def unregister(): + del bpy.types.Object.mmd_joint diff --git a/core/mmd/properties/root.py b/core/mmd/properties/root.py index 6423a1e..8188ed1 100644 --- a/core/mmd/properties/root.py +++ b/core/mmd/properties/root.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. """Properties for MMD model root object""" @@ -500,26 +499,22 @@ class MMDRoot(bpy.types.PropertyGroup): @staticmethod def __get_select(prop: bpy.types.Object) -> bool: - # TODO: Object.select is deprecated since v4.0.0, use Object.select_get() method instead - # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") + utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") return prop.select_get() @staticmethod def __set_select(prop: bpy.types.Object, value: bool) -> None: - # TODO: Object.select is deprecated since v4.0.0, use Object.select_set() method instead - # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") + utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") prop.select_set(value) @staticmethod def __get_hide(prop: bpy.types.Object) -> bool: - # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_get() method instead - # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") + utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") return prop.hide_get() @staticmethod def __set_hide(prop: bpy.types.Object, value: bool) -> None: - # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_set() method instead - # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") + utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") prop.hide_set(value) if prop.hide_viewport != value: prop.hide_viewport = value @@ -579,4 +574,4 @@ class MMDRoot(bpy.types.PropertyGroup): del bpy.types.Object.hide del bpy.types.Object.select del bpy.types.Object.mmd_root - del bpy.types.Object.mmd_type \ No newline at end of file + del bpy.types.Object.mmd_type diff --git a/core/mmd/properties/translations.py b/core/mmd/properties/translations.py new file mode 100644 index 0000000..a70a9fc --- /dev/null +++ b/core/mmd/properties/translations.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Dict, List, Optional, Tuple + +import bpy + +from ..core.translations import FnTranslations, MMDTranslationElementType +from ..translations import DictionaryEnum + +MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS = [ + (MMDTranslationElementType.BONE.name, MMDTranslationElementType.BONE.value, "Bones", 1), + (MMDTranslationElementType.MORPH.name, MMDTranslationElementType.MORPH.value, "Morphs", 2), + (MMDTranslationElementType.MATERIAL.name, MMDTranslationElementType.MATERIAL.value, "Materials", 4), + (MMDTranslationElementType.DISPLAY.name, MMDTranslationElementType.DISPLAY.value, "Display frames", 8), + (MMDTranslationElementType.PHYSICS.name, MMDTranslationElementType.PHYSICS.value, "Rigidbodies and joints", 16), + (MMDTranslationElementType.INFO.name, MMDTranslationElementType.INFO.value, "Model name and comments", 32), +] + + +class MMDTranslationElement(bpy.types.PropertyGroup): + type: bpy.props.EnumProperty(items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS) + object: bpy.props.PointerProperty(type=bpy.types.Object) + data_path: bpy.props.StringProperty() + name: bpy.props.StringProperty() + name_j: bpy.props.StringProperty() + name_e: bpy.props.StringProperty() + + +class MMDTranslationElementIndex(bpy.types.PropertyGroup): + value: bpy.props.IntProperty() + + +BATCH_OPERATION_SCRIPT_PRESETS: Dict[str, Tuple[Optional[str], str, str, int]] = { + "NOTHING": ("", "", "", 1), + "CLEAR": (None, "Clear", '""', 10), + "TO_ENGLISH": ("BLENDER", "Translate to English", "to_english(name)", 2), + "TO_MMD_LR": ("JAPANESE", "Blender L/R to MMD L/R", "to_mmd_lr(name)", 3), + "TO_BLENDER_LR": ("BLENDER", "MMD L/R to Blender L/R", "to_blender_lr(name_j)", 4), + "RESTORE_BLENDER": ("BLENDER", "Restore Blender Names", "org_name", 5), + "RESTORE_JAPANESE": ("JAPANESE", "Restore Japanese MMD Names", "org_name_j", 6), + "RESTORE_ENGLISH": ("ENGLISH", "Restore English MMD Names", "org_name_e", 7), + "ENGLISH_IF_EMPTY_JAPANESE": (None, "Copy English MMD Names, if empty copy Japanese MMD Name", "name_e if name_e else name_j", 8), + "JAPANESE_IF_EMPTY_ENGLISH": (None, "Copy Japanese MMD Names, if empty copy English MMD Name", "name_j if name_j else name_e", 9), +} + +BATCH_OPERATION_SCRIPT_PRESET_ITEMS: List[Tuple[str, str, str, int]] = [(k, t[1], t[2], t[3]) for k, t in BATCH_OPERATION_SCRIPT_PRESETS.items()] + + +class MMDTranslation(bpy.types.PropertyGroup): + @staticmethod + def _update_index(mmd_translation: "MMDTranslation", _context): + FnTranslations.update_index(mmd_translation) + + @staticmethod + def _collect_data(mmd_translation: "MMDTranslation", _context): + FnTranslations.collect_data(mmd_translation) + + @staticmethod + def _update_query(mmd_translation: "MMDTranslation", _context): + FnTranslations.update_query(mmd_translation) + + @staticmethod + def _update_batch_operation_script_preset(mmd_translation: "MMDTranslation", _context): + if mmd_translation.batch_operation_script_preset == "NOTHING": + return + + id2scripts: Dict[str, str] = {i[0]: i[2] for i in BATCH_OPERATION_SCRIPT_PRESET_ITEMS} + + batch_operation_script = id2scripts.get(mmd_translation.batch_operation_script_preset) + if batch_operation_script is None: + return + + mmd_translation.batch_operation_script = batch_operation_script + batch_operation_target = BATCH_OPERATION_SCRIPT_PRESETS[mmd_translation.batch_operation_script_preset][0] + if batch_operation_target: + mmd_translation.batch_operation_target = batch_operation_target + + translation_elements: bpy.props.CollectionProperty(type=MMDTranslationElement) + filtered_translation_element_indices_active_index: bpy.props.IntProperty(update=_update_index.__func__) + filtered_translation_element_indices: bpy.props.CollectionProperty(type=MMDTranslationElementIndex) + + filter_japanese_blank: bpy.props.BoolProperty(name="Japanese Blank", default=False, update=_update_query.__func__) + filter_english_blank: bpy.props.BoolProperty(name="English Blank", default=False, update=_update_query.__func__) + filter_restorable: bpy.props.BoolProperty(name="Restorable", default=False, update=_update_query.__func__) + filter_selected: bpy.props.BoolProperty(name="Selected", default=False, update=_update_query.__func__) + filter_visible: bpy.props.BoolProperty(name="Visible", default=False, update=_update_query.__func__) + filter_types: bpy.props.EnumProperty( + items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS, + default={ + "BONE", + "MORPH", + "MATERIAL", + "DISPLAY", + "PHYSICS", + }, + options={"ENUM_FLAG"}, + update=_update_query.__func__, + ) + + dictionary: bpy.props.EnumProperty( + items=DictionaryEnum.get_dictionary_items, + name="Dictionary", + ) + + batch_operation_target: bpy.props.EnumProperty( + items=[ + ("BLENDER", "Blender Name (name)", "", 1), + ("JAPANESE", "Japanese MMD Name (name_j)", "", 2), + ("ENGLISH", "English MMD Name (name_e)", "", 3), + ], + name="Operation Target", + default="JAPANESE", + ) + + batch_operation_script_preset: bpy.props.EnumProperty( + items=BATCH_OPERATION_SCRIPT_PRESET_ITEMS, + name="Operation Script Preset", + default="NOTHING", + update=_update_batch_operation_script_preset.__func__, + ) + + batch_operation_script: bpy.props.StringProperty() diff --git a/core/mmd/translations.py b/core/mmd/translations.py new file mode 100644 index 0000000..b7f5e3c --- /dev/null +++ b/core/mmd/translations.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import csv +import logging +import time + +import bpy + +from .bpyutils import FnContext + +jp_half_to_full_tuples = ( + ("ヴ", "ヴ"), + ("ガ", "ガ"), + ("ギ", "ギ"), + ("グ", "グ"), + ("ゲ", "ゲ"), + ("ゴ", "ゴ"), + ("ザ", "ザ"), + ("ジ", "ジ"), + ("ズ", "ズ"), + ("ゼ", "ゼ"), + ("ゾ", "ゾ"), + ("ダ", "ダ"), + ("ヂ", "ヂ"), + ("ヅ", "ヅ"), + ("デ", "デ"), + ("ド", "ド"), + ("バ", "バ"), + ("パ", "パ"), + ("ビ", "ビ"), + ("ピ", "ピ"), + ("ブ", "ブ"), + ("プ", "プ"), + ("ベ", "ベ"), + ("ペ", "ペ"), + ("ボ", "ボ"), + ("ポ", "ポ"), + ("。", "。"), + ("「", "「"), + ("」", "」"), + ("、", "、"), + ("・", "・"), + ("ヲ", "ヲ"), + ("ァ", "ァ"), + ("ィ", "ィ"), + ("ゥ", "ゥ"), + ("ェ", "ェ"), + ("ォ", "ォ"), + ("ャ", "ャ"), + ("ュ", "ュ"), + ("ョ", "ョ"), + ("ッ", "ッ"), + ("ー", "ー"), + ("ア", "ア"), + ("イ", "イ"), + ("ウ", "ウ"), + ("エ", "エ"), + ("オ", "オ"), + ("カ", "カ"), + ("キ", "キ"), + ("ク", "ク"), + ("ケ", "ケ"), + ("コ", "コ"), + ("サ", "サ"), + ("シ", "シ"), + ("ス", "ス"), + ("セ", "セ"), + ("ソ", "ソ"), + ("タ", "タ"), + ("チ", "チ"), + ("ツ", "ツ"), + ("テ", "テ"), + ("ト", "ト"), + ("ナ", "ナ"), + ("ニ", "ニ"), + ("ヌ", "ヌ"), + ("ネ", "ネ"), + ("ノ", "ノ"), + ("ハ", "ハ"), + ("ヒ", "ヒ"), + ("フ", "フ"), + ("ヘ", "ヘ"), + ("ホ", "ホ"), + ("マ", "マ"), + ("ミ", "ミ"), + ("ム", "ム"), + ("メ", "メ"), + ("モ", "モ"), + ("ヤ", "ヤ"), + ("ユ", "ユ"), + ("ヨ", "ヨ"), + ("ラ", "ラ"), + ("リ", "リ"), + ("ル", "ル"), + ("レ", "レ"), + ("ロ", "ロ"), + ("ワ", "ワ"), + ("ン", "ン"), +) + +jp_to_en_tuples = [ + ("全ての親", "ParentNode"), + ("操作中心", "ControlNode"), + ("センター", "Center"), + ("センター", "Center"), + ("グループ", "Group"), + ("グルーブ", "Groove"), + ("キャンセル", "Cancel"), + ("上半身", "UpperBody"), + ("下半身", "LowerBody"), + ("手首", "Wrist"), + ("足首", "Ankle"), + ("首", "Neck"), + ("頭", "Head"), + ("顔", "Face"), + ("下顎", "Chin"), + ("下あご", "Chin"), + ("あご", "Jaw"), + ("顎", "Jaw"), + ("両目", "Eyes"), + ("目", "Eye"), + ("眉", "Eyebrow"), + ("舌", "Tongue"), + ("涙", "Tears"), + ("泣き", "Cry"), + ("歯", "Teeth"), + ("照れ", "Blush"), + ("青ざめ", "Pale"), + ("ガーン", "Gloom"), + ("汗", "Sweat"), + ("怒", "Anger"), + ("感情", "Emotion"), + ("符", "Marks"), + ("暗い", "Dark"), + ("腰", "Waist"), + ("髪", "Hair"), + ("三つ編み", "Braid"), + ("胸", "Breast"), + ("乳", "Boob"), + ("おっぱい", "Tits"), + ("筋", "Muscle"), + ("腹", "Belly"), + ("鎖骨", "Clavicle"), + ("肩", "Shoulder"), + ("腕", "Arm"), + ("うで", "Arm"), + ("ひじ", "Elbow"), + ("肘", "Elbow"), + ("手", "Hand"), + ("親指", "Thumb"), + ("人指", "IndexFinger"), + ("人差指", "IndexFinger"), + ("中指", "MiddleFinger"), + ("薬指", "RingFinger"), + ("小指", "LittleFinger"), + ("足", "Leg"), + ("ひざ", "Knee"), + ("つま", "Toe"), + ("袖", "Sleeve"), + ("新規", "New"), + ("ボーン", "Bone"), + ("捩", "Twist"), + ("回転", "Rotation"), + ("軸", "Axis"), + ("ネクタイ", "Necktie"), + ("ネクタイ", "Necktie"), + ("ヘッドセット", "Headset"), + ("飾り", "Accessory"), + ("リボン", "Ribbon"), + ("襟", "Collar"), + ("紐", "String"), + ("コード", "Cord"), + ("イヤリング", "Earring"), + ("メガネ", "Eyeglasses"), + ("眼鏡", "Glasses"), + ("帽子", "Hat"), + ("スカート", "Skirt"), + ("スカート", "Skirt"), + ("パンツ", "Pantsu"), + ("シャツ", "Shirt"), + ("フリル", "Frill"), + ("マフラー", "Muffler"), + ("マフラー", "Muffler"), + ("服", "Clothes"), + ("ブーツ", "Boots"), + ("ねこみみ", "CatEars"), + ("ジップ", "Zip"), + ("ジップ", "Zip"), + ("ダミー", "Dummy"), + ("ダミー", "Dummy"), + ("基", "Category"), + ("あほ毛", "Antenna"), + ("アホ毛", "Antenna"), + ("モミアゲ", "Sideburn"), + ("もみあげ", "Sideburn"), + ("ツインテ", "Twintail"), + ("おさげ", "Pigtail"), + ("ひらひら", "Flutter"), + ("調整", "Adjustment"), + ("補助", "Aux"), + ("右", "Right"), + ("左", "Left"), + ("前", "Front"), + ("後ろ", "Behind"), + ("後", "Back"), + ("横", "Side"), + ("中", "Middle"), + ("上", "Upper"), + ("下", "Lower"), + ("親", "Parent"), + ("先", "Tip"), + ("パーツ", "Part"), + ("光", "Light"), + ("戻", "Return"), + ("羽", "Wing"), + ("根", "Base"), # ideally 'Root' but to avoid confusion + ("毛", "Strand"), + ("尾", "Tail"), + ("尻", "Butt"), + # full-width unicode forms I think: https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms + ("0", "0"), + ("1", "1"), + ("2", "2"), + ("3", "3"), + ("4", "4"), + ("5", "5"), + ("6", "6"), + ("7", "7"), + ("8", "8"), + ("9", "9"), + ("a", "a"), + ("b", "b"), + ("c", "c"), + ("d", "d"), + ("e", "e"), + ("f", "f"), + ("g", "g"), + ("h", "h"), + ("i", "i"), + ("j", "j"), + ("k", "k"), + ("l", "l"), + ("m", "m"), + ("n", "n"), + ("o", "o"), + ("p", "p"), + ("q", "q"), + ("r", "r"), + ("s", "s"), + ("t", "t"), + ("u", "u"), + ("v", "v"), + ("w", "w"), + ("x", "x"), + ("y", "y"), + ("z", "z"), + ("A", "A"), + ("B", "B"), + ("C", "C"), + ("D", "D"), + ("E", "E"), + ("F", "F"), + ("G", "G"), + ("H", "H"), + ("I", "I"), + ("J", "J"), + ("K", "K"), + ("L", "L"), + ("M", "M"), + ("N", "N"), + ("O", "O"), + ("P", "P"), + ("Q", "Q"), + ("R", "R"), + ("S", "S"), + ("T", "T"), + ("U", "U"), + ("V", "V"), + ("W", "W"), + ("X", "X"), + ("Y", "Y"), + ("Z", "Z"), + ("+", "+"), + ("-", "-"), + ("_", "_"), + ("/", "/"), + (".", "_"), # probably should be combined with the global 'use underscore' option +] + + +def translateFromJp(name): + for tuple in jp_to_en_tuples: + if tuple[0] in name: + name = name.replace(tuple[0], tuple[1]) + return name + + +def getTranslator(csvfile="", keep_order=False): + translator = MMDTranslator() + if isinstance(csvfile, bpy.types.Text): + translator.load_from_stream(csvfile) + elif isinstance(csvfile, dict): + translator.csv_tuples.extend(csvfile.items()) + elif csvfile in bpy.data.texts.keys(): + translator.load_from_stream(bpy.data.texts[csvfile]) + else: + translator.load(csvfile) + + if not keep_order: + translator.sort() + translator.update() + return translator + + +class MMDTranslator: + def __init__(self): + self.__csv_tuples = [] + self.__fails = {} + + @staticmethod + def default_csv_filepath(): + return __file__[:-3] + ".csv" + + @staticmethod + def get_csv_text(text_name=None): + text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath()) + csv_text = bpy.data.texts.get(text_name, None) + if csv_text is None: + csv_text = bpy.data.texts.new(text_name) + return csv_text + + @staticmethod + def replace_from_tuples(name, tuples): + for pair in tuples: + if pair[0] in name: + name = name.replace(pair[0], pair[1]) + return name + + @property + def csv_tuples(self): + return self.__csv_tuples + + @property + def fails(self): + return self.__fails + + def sort(self): + self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row)) + + def update(self): + 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) + + def half_to_full(self, name): + return self.replace_from_tuples(name, jp_half_to_full_tuples) + + def is_translated(self, name): + try: + name.encode("ascii", errors="strict") + except UnicodeEncodeError: + return False + return True + + def translate(self, name, default=None, from_full_width=True): + if from_full_width: + name = self.half_to_full(name) + name_new = self.replace_from_tuples(name, self.__csv_tuples) + if default is not None and not self.is_translated(name_new): + self.__fails[name] = name_new + return default + return name_new + + def save_fails(self, text_name=None): + text_name = text_name or (__name__ + ".fails") + txt = self.get_csv_text(text_name) + fmt = '"%s","%s"' + items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row)) + txt.from_string("\n".join(fmt % (k, v) for k, v in items)) + return txt + + def load_from_stream(self, csvfile=None): + csvfile = csvfile or self.get_csv_text() + if isinstance(csvfile, bpy.types.Text): + csvfile = (l.body + "\n" for l in csvfile.lines) + 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)) + + def save_to_stream(self, csvfile=None): + csvfile = csvfile or self.get_csv_text() + lineterminator = "\r\n" + if isinstance(csvfile, bpy.types.Text): + csvfile.clear() + 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)) + + def load(self, filepath=None): + 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) + + def save(self, filepath=None): + 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) + + +class DictionaryEnum: + __items_ttl = 0.0 + __items_cache = None + + @staticmethod + def get_dictionary_items(prop, context): + if DictionaryEnum.__items_ttl > time.time(): + return DictionaryEnum.__items_cache + + DictionaryEnum.__items_ttl = time.time() + 5 + DictionaryEnum.__items_cache = items = [] + if "import" in prop.bl_rna.identifier: + items.append(("DISABLED", "Disabled", "", 0)) + + 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))) + + import os + + folder = FnContext.get_addon_preferences_attribute(context, "dictionary_folder", "") + if os.path.isdir(folder): + for filename in sorted(x for x in os.listdir(folder) if x.lower().endswith(".csv")): + filepath = os.path.join(folder, filename) + if os.path.isfile(filepath): + items.append((filepath, filename, filepath, "FILE", len(items))) + + if "dictionary" in prop: + prop["dictionary"] = min(prop["dictionary"], len(items) - 1) + return items + + @staticmethod + def get_translator(dictionary): + if dictionary == "DISABLED": + return None + if dictionary == "INTERNAL": + return getTranslator(dict(jp_to_en_tuples)) + return getTranslator(dictionary) diff --git a/core/mmd/core/utils.py b/core/mmd/utils.py similarity index 52% rename from core/mmd/core/utils.py rename to core/mmd/utils.py index 4a6f5df..c4006ac 100644 --- a/core/mmd/core/utils.py +++ b/core/mmd/utils.py @@ -1,85 +1,75 @@ # -*- coding: utf-8 -*- -# Copyright 2013 MMD Tools authors -# This file was originally part of the MMD Tools project, However Neoneko has added it to Avatar Toolkit. -# All credit goes to the original authors. -# Please note that some code was modified to fit the needs of Avatar Toolkit and some code may of been removed. -# MMD Tools is licensed under the terms of the GPL-3.0 license which Avatar Toolkit is also licensed under. -# You can find MMD Tools at: https://github.com/MMD-Blender/blender_mmd_tools/ +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import logging import os import re -from typing import Callable, Optional, Set, List, Dict, Any +from typing import Callable, Optional, Set import bpy -from bpy.types import Object, Context, Bone, PoseBone -from ...logging_setup import logger from .bpyutils import FnContext -def selectAObject(obj: Object) -> None: - """Select a single object and make it active""" +## 指定したオブジェクトのみを選択状態かつアクティブにする +def selectAObject(obj): try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: - logger.debug(f"Failed to set object mode for {obj.name}") - + pass bpy.ops.object.select_all(action="DESELECT") FnContext.select_object(FnContext.ensure_context(), obj) FnContext.set_active_object(FnContext.ensure_context(), obj) -def enterEditMode(obj: Object) -> None: - """Enter edit mode for the specified object""" +## 現在のモードを指定したオブジェクトのEdit Modeに変更する +def enterEditMode(obj): selectAObject(obj) if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") -def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: - """Set an object's parent to a specific bone""" +def setParentToBone(obj, parent, bone_name): selectAObject(obj) FnContext.set_active_object(FnContext.ensure_context(), parent) bpy.ops.object.mode_set(mode="POSE") parent.data.bones.active = parent.data.bones[bone_name] - bpy.ops.object.parent_set(type="BONE", keep_transform=False) + bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False) bpy.ops.object.mode_set(mode="OBJECT") -def selectSingleBone(context: Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None: - """Select a single bone in an armature""" +def selectSingleBone(context, armature, bone_name, reset_pose=False): try: bpy.ops.object.mode_set(mode="OBJECT") - except Exception: - logger.debug(f"Failed to set object mode for bone selection: {bone_name}") - + except: + pass for i in context.selected_objects: i.select_set(False) - FnContext.set_active_object(context, armature) bpy.ops.object.mode_set(mode="POSE") - if reset_pose: for p_bone in armature.pose.bones: p_bone.matrix_basis.identity() - - armature_bones = armature.data.bones - for bone in armature_bones: - bone.select = bone.name == bone_name - bone.select_head = bone.select_tail = bone.select - if bone.select: - armature_bones.active = bone - bone.hide = False + armature_bones: bpy.types.ArmatureBones = armature.data.bones + i: bpy.types.Bone + for i in armature_bones: + i.select = i.name == bone_name + i.select_head = i.select_tail = i.select + if i.select: + armature_bones.active = i + i.hide = False -# Regular expressions for name conversion __CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$") __CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$") -def convertNameToLR(name: str, use_underscore: bool = False) -> str: - """Convert Japanese left/right naming to Blender's L/R convention""" +## 日本語で左右を命名されている名前をblender方式のL(R)に変更する +def convertNameToLR(name, use_underscore=False): m = __CONVERT_NAME_TO_L_REGEXP.match(name) delimiter = "_" if use_underscore else "." if m: @@ -94,8 +84,7 @@ __CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[lL])(?P(?P[._])[rR])(?P($|(?P=separator)))") -def convertLRToName(name: str) -> str: - """Convert Blender's L/R convention to Japanese left/right naming""" +def convertLRToName(name): match = __CONVERT_L_TO_NAME_REGEXP.search(name) if match: return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}" @@ -107,8 +96,8 @@ def convertLRToName(name: str) -> str: return name -def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_group_name: str) -> None: - """Merge weights from source vertex group to destination vertex group""" +## src_vertex_groupのWeightをdest_vertex_groupにaddする +def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name): mesh = meshObj.data src_vertex_group = meshObj.vertex_groups[src_vertex_group_name] dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name] @@ -122,38 +111,30 @@ def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_gr pass -def separateByMaterials(meshObj: Object) -> None: - """Separate a mesh object by materials""" +def separateByMaterials(meshObj: bpy.types.Object): if len(meshObj.data.materials) < 2: selectAObject(meshObj) return - matrix_parent_inverse = meshObj.matrix_parent_inverse.copy() prev_parent = meshObj.parent dummy_parent = bpy.data.objects.new(name="tmp", object_data=None) - bpy.context.collection.objects.link(dummy_parent) - meshObj.parent = dummy_parent meshObj.active_shape_key_index = 0 - try: enterEditMode(meshObj) bpy.ops.mesh.select_all(action="SELECT") bpy.ops.mesh.separate(type="MATERIAL") finally: bpy.ops.object.mode_set(mode="OBJECT") - for i in dummy_parent.children: materials = i.data.materials i.name = getattr(materials[0], "name", "None") if len(materials) else "None" i.parent = prev_parent i.matrix_parent_inverse = matrix_parent_inverse - bpy.data.objects.remove(dummy_parent) -def clearUnusedMeshes() -> None: - """Remove unused mesh data blocks""" +def clearUnusedMeshes(): meshes_to_delete = [] for mesh in bpy.data.meshes: if mesh.users == 0: @@ -163,44 +144,72 @@ def clearUnusedMeshes() -> None: bpy.data.meshes.remove(mesh) -def makePmxBoneMap(armObj: Object) -> Dict[str, PoseBone]: - """Create a mapping from bone names to pose bones""" - return {(i.mmd_bone.name_j or i.name): i for i in armObj.pose.bones} +## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を +# それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成 +def makePmxBoneMap(armObj): + # 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} __REMOVE_PREFIX_DIGITS_REGEXP = re.compile(r"\.\d{1,}$") def unique_name(name: str, used_names: Set[str]) -> str: - """Create a unique name that doesn't exist in the used_names set - + """Helper function for storing unique names. + This function is a limited and simplified version of bpy_extras.io_utils.unique_name. + Args: - name (str): The name to make unique - used_names (Set[str]): A set of names that are already used - + name (str): The name to make unique. + used_names (Set[str]): A set of names that are already used. + Returns: - str: The unique name, formatted as "{name}.{number:03d}" + str: The unique name, formatted as "{name}.{number:03d}". """ if name not in used_names: return name - count = 1 new_name = orig_name = __REMOVE_PREFIX_DIGITS_REGEXP.sub("", name) - while new_name in used_names: new_name = f"{orig_name}.{count:03d}" count += 1 - return new_name -def saferelpath(path: str, start: str, strategy: str = "inside") -> str: - """Safely get a relative path, handling different drive issues on Windows - +def int2base(x, base, width=0): + """ + Method to convert an int to a base + Source: http://stackoverflow.com/questions/2267362 + """ + import string + + digs = string.digits + string.ascii_uppercase + assert 2 <= base <= len(digs) + digits, negtive = "", False + if x <= 0: + if x == 0: + return "0" * max(1, width) + x, negtive, width = -x, True, width - 1 + while x: + digits = digs[x % base] + digits + x //= base + digits = "0" * (width - len(digits)) + digits + if negtive: + digits = "-" + digits + return digits + + +def saferelpath(path, start, strategy="inside"): + """ + On Windows relpath will raise a ValueError + when trying to calculate the relative path to a + different drive. + This method will behave different depending on the strategy + choosen to handle the different drive issue. Strategies: - - inside: returns the basename of the path - - outside: prepends '..' to the basename if on different drive - - absolute: returns the absolute path + - inside: this will just return the basename of the path given + - outside: this will prepend '..' to the basename + - absolute: this will return the absolute path instead of a relative. + See http://bugs.python.org/issue7195 """ if strategy == "inside": return os.path.basename(path) @@ -216,20 +225,15 @@ def saferelpath(path: str, start: str, strategy: str = "inside") -> str: return os.path.relpath(path, start) - class ItemOp: - """Operations for managing collections of items""" - @staticmethod - def get_by_index(items: List[Any], index: int) -> Optional[Any]: - """Get an item by index with bounds checking""" + def get_by_index(items, index): if 0 <= index < len(items): return items[index] return None @staticmethod - def resize(items: bpy.types.bpy_prop_collection, length: int) -> None: - """Resize a collection to the specified length""" + def resize(items: bpy.types.bpy_prop_collection, length: int): count = length - len(items) if count > 0: for i in range(count): @@ -239,8 +243,7 @@ class ItemOp: items.remove(length) @staticmethod - def add_after(items: bpy.types.bpy_prop_collection, index: int) -> tuple: - """Add a new item after the specified index""" + def add_after(items, index): index_end = len(items) index = max(0, min(index_end, index + 1)) items.add() @@ -249,28 +252,24 @@ class ItemOp: class ItemMoveOp: - """Operations for moving items in collections""" - + type: bpy.props.EnumProperty( + name="Type", + description="Move type", + items=[ + ("UP", "Up", "", 0), + ("DOWN", "Down", "", 1), + ("TOP", "Top", "", 2), + ("BOTTOM", "Bottom", "", 3), + ], + default="UP", + ) + @staticmethod - def move(items: bpy.types.bpy_prop_collection, index: int, move_type: str, - index_min: int = 0, index_max: Optional[int] = None) -> int: - """Move an item in a collection - - Args: - items: The collection to modify - index: Current index of the item - move_type: Type of move ('UP', 'DOWN', 'TOP', 'BOTTOM') - index_min: Minimum allowed index - index_max: Maximum allowed index - - Returns: - int: The new index after moving - """ + def move(items, index, move_type, index_min=0, index_max=None): if index_max is None: index_max = len(items) - 1 else: index_max = min(index_max, len(items) - 1) - index_min = min(index_min, index_max) if index < index_min: @@ -292,5 +291,44 @@ class ItemMoveOp: if index_new != index: items.move(index, index_new) - return index_new + + +def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None): + """Decorator to mark a function as deprecated. + Args: + deprecated_in (Optional[str]): Version in which the function was deprecated. + details (Optional[str]): Additional details about the deprecation. + Returns: + Callable: The decorated function. + """ + + def _function_wrapper(function: Callable): + def _inner_wrapper(*args, **kwargs): + warn_deprecation(function.__name__, deprecated_in, details) + return function(*args, **kwargs) + + return _inner_wrapper + + return _function_wrapper + + +def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, details: Optional[str] = None) -> None: + """Reports a deprecation warning. + Args: + function_name (str): Name of the deprecated function. + deprecated_in (Optional[str]): Version in which the function was deprecated. + details (Optional[str]): Additional details about the deprecation. + """ + logging.warning( + "%s is deprecated%s%s", + function_name, + f" since {deprecated_in}" if deprecated_in else "", + f": {details}" if details else "", + 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/cycles_converter.py b/cycles_converter.py new file mode 100644 index 0000000..f0d391a --- /dev/null +++ b/cycles_converter.py @@ -0,0 +1,240 @@ +# -*- 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) From c31d25dd01f48469574800e44a88a8794cfffaf0 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Fri, 11 Apr 2025 23:45:36 +0100 Subject: [PATCH 17/32] Update Logging You can choose between errors, warning, info or full debug, errors will always log to ensure we don't have silent failures with debug on or off. --- __init__.py | 5 + core/auto_load.py | 3 +- core/importers/importer.py | 40 + core/lamp.py | 66 ++ core/logging_setup.py | 31 +- core/mmd/__init__.py | 26 + core/mmd/bpyutils.py | 521 +++++++++ core/mmd/core/__init__.py | 6 + core/mmd/core/bone.py | 564 ++++++++++ core/mmd/core/camera.py | 257 +++++ core/mmd/core/exceptions.py | 14 + core/mmd/core/lamp.py | 69 ++ core/mmd/core/material.py | 718 ++++++++++++ core/mmd/core/model.py | 1208 ++++++++++++++++++++ core/mmd/core/morph.py | 798 +++++++++++++ core/mmd/core/pmx/__init__.py | 1625 +++++++++++++++++++++++++++ core/mmd/core/pmx/importer.py | 1035 +++++++++++++++++ core/mmd/core/rigid_body.py | 290 +++++ core/mmd/core/sdef.py | 334 ++++++ core/mmd/core/shader.py | 346 ++++++ core/mmd/core/translations.py | 738 ++++++++++++ core/mmd/core/vmd/__init__.py | 6 + core/mmd/core/vmd/importer.py | 673 +++++++++++ core/mmd/cycles_converter.py | 243 ++++ core/mmd/operators/__init__.py | 6 + core/mmd/operators/material.py | 406 +++++++ core/mmd/operators/misc.py | 310 +++++ core/mmd/operators/model.py | 486 ++++++++ core/mmd/operators/model_edit.py | 313 ++++++ core/mmd/operators/morph.py | 776 +++++++++++++ core/mmd/operators/rigid_body.py | 579 ++++++++++ core/mmd/operators/sdef.py | 110 ++ core/mmd/operators/translations.py | 336 ++++++ core/mmd/operators/view.py | 150 +++ core/mmd/properties/__init__.py | 34 + core/mmd/properties/material.py | 287 +++++ core/mmd/properties/morph.py | 488 ++++++++ core/mmd/properties/pose_bone.py | 224 ++++ core/mmd/properties/rigid_body.py | 295 +++++ core/mmd/properties/root.py | 577 ++++++++++ core/mmd/properties/translations.py | 127 +++ core/mmd/translations.py | 461 ++++++++ core/mmd/utils.py | 334 ++++++ core/properties.py | 20 + resources/translations/en_US.json | 10 + resources/translations/ja_JP.json | 10 + resources/translations/ko_KR.json | 10 + ui/settings_panel.py | 3 + 48 files changed, 15954 insertions(+), 14 deletions(-) create mode 100644 core/lamp.py create mode 100644 core/mmd/__init__.py create mode 100644 core/mmd/bpyutils.py create mode 100644 core/mmd/core/__init__.py create mode 100644 core/mmd/core/bone.py create mode 100644 core/mmd/core/camera.py create mode 100644 core/mmd/core/exceptions.py create mode 100644 core/mmd/core/lamp.py create mode 100644 core/mmd/core/material.py create mode 100644 core/mmd/core/model.py create mode 100644 core/mmd/core/morph.py create mode 100644 core/mmd/core/pmx/__init__.py create mode 100644 core/mmd/core/pmx/importer.py create mode 100644 core/mmd/core/rigid_body.py create mode 100644 core/mmd/core/sdef.py create mode 100644 core/mmd/core/shader.py create mode 100644 core/mmd/core/translations.py create mode 100644 core/mmd/core/vmd/__init__.py create mode 100644 core/mmd/core/vmd/importer.py create mode 100644 core/mmd/cycles_converter.py create mode 100644 core/mmd/operators/__init__.py create mode 100644 core/mmd/operators/material.py create mode 100644 core/mmd/operators/misc.py create mode 100644 core/mmd/operators/model.py create mode 100644 core/mmd/operators/model_edit.py create mode 100644 core/mmd/operators/morph.py create mode 100644 core/mmd/operators/rigid_body.py create mode 100644 core/mmd/operators/sdef.py create mode 100644 core/mmd/operators/translations.py create mode 100644 core/mmd/operators/view.py create mode 100644 core/mmd/properties/__init__.py create mode 100644 core/mmd/properties/material.py create mode 100644 core/mmd/properties/morph.py create mode 100644 core/mmd/properties/pose_bone.py create mode 100644 core/mmd/properties/rigid_body.py create mode 100644 core/mmd/properties/root.py create mode 100644 core/mmd/properties/translations.py create mode 100644 core/mmd/translations.py create mode 100644 core/mmd/utils.py diff --git a/__init__.py b/__init__.py index dedf6b3..1b5d169 100644 --- a/__init__.py +++ b/__init__.py @@ -25,6 +25,7 @@ def register(): from . import core from .core import auto_load from .core.logging_setup import configure_logging + from .core.addon_preferences import get_preference # Initialize logging configure_logging(False) @@ -36,6 +37,10 @@ def register(): if not hasattr(bpy.types.Scene, "avatar_toolkit"): from .core.properties import register as register_properties register_properties() + + if hasattr(bpy.types.Scene, "avatar_toolkit"): + log_level = get_preference("log_level", "WARNING") + configure_logging(get_preference("enable_logging", False), log_level) print("Registration complete") diff --git a/core/auto_load.py b/core/auto_load.py index dc326e6..a6ca0ad 100644 --- a/core/auto_load.py +++ b/core/auto_load.py @@ -27,7 +27,8 @@ def init() -> None: configure_logging(False) from .addon_preferences import get_preference - configure_logging(get_preference("enable_logging", False)) + log_level = get_preference("log_level", "WARNING") + configure_logging(get_preference("enable_logging", False), log_level) print("Auto-load init starting") diff --git a/core/importers/importer.py b/core/importers/importer.py index feb8e93..237fc92 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -8,6 +8,7 @@ from bpy_extras.io_utils import ImportHelper from typing import Optional, Callable, Dict, List, Union, Set from ..common import clear_default_objects from ..translations import t +from ..mmd.core.pmx.importer import PMXImporter # Configure logging logging.basicConfig(level=logging.INFO) @@ -94,6 +95,12 @@ import_types: Dict[str, ImportMethod] = { files=files, directory=directory, filepath=filepath, automatic_bone_orientation=False, use_prepost_rot=False, use_anim=False ), + "pmx": lambda directory, files, filepath: import_multi_files( + directory=directory, + files=files, + filepath=filepath, + method=lambda directory, filepath: import_pmx_file(filepath) + ), "smd": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), "dmx": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), "gltf": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath), @@ -193,3 +200,36 @@ class AvatarToolKit_OT_Import(Operator, ImportHelper): self.report({'INFO'}, t('Quick_Access.import_success')) return {'FINISHED'} +def import_pmx_file(filepath: str) -> None: + """ + Import a PMX file using the MMD Tools PMXImporter + + Args: + filepath: Path to the PMX file + """ + + # Default import settings + import_settings = { + "filepath": filepath, + "scale": 0.08, + "types": {"MESH", "ARMATURE", "MORPHS", "DISPLAY"}, + "clean_model": True, + "remove_doubles": False, + "fix_IK_links": True, + "ik_loop_factor": 3, + "use_mipmap": True, + "sph_blend_factor": 1.0, + "spa_blend_factor": 1.0, + "rename_LR_bones": False, + "use_underscore": False, + "apply_bone_fixed_axis": False, + } + + # Create and execute the importer + importer = PMXImporter() + try: + importer.execute(**import_settings) + logger.info(f"Successfully imported PMX file: {filepath}") + except Exception as e: + logger.error(f"Failed to import PMX file: {str(e)}", exc_info=True) + raise diff --git a/core/lamp.py b/core/lamp.py new file mode 100644 index 0000000..10593d3 --- /dev/null +++ b/core/lamp.py @@ -0,0 +1,66 @@ +# -*- 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/logging_setup.py b/core/logging_setup.py index 9e86872..434274d 100644 --- a/core/logging_setup.py +++ b/core/logging_setup.py @@ -6,17 +6,28 @@ from bpy.types import Context logger = logging.getLogger('avatar_toolkit') _original_error = logger.error -def configure_logging(enabled: bool = False) -> None: - """Configure logging for Avatar Toolkit""" - logger.setLevel(logging.DEBUG if enabled else logging.WARNING) +def configure_logging(enabled: bool = False, level: str = "WARNING") -> None: + """Configure logging for Avatar Toolkit """ + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR + } + + log_level = level_map.get(level, logging.WARNING) + + if enabled: + logger.setLevel(log_level) + else: + logger.setLevel(logging.ERROR) # We should still log errors when logging is disabled so we don't have silent failures - # Remove existing handlers for handler in logger.handlers[:]: logger.removeHandler(handler) if enabled: handler = logging.StreamHandler() - handler.setLevel(logging.DEBUG) + handler.setLevel(log_level) formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) @@ -34,12 +45,6 @@ def update_logging_state(self: Any, context: Context) -> None: """Update logging state based on user preference""" from .addon_preferences import save_preference enabled = self.enable_logging + level = self.log_level if hasattr(self, "log_level") else "WARNING" save_preference("enable_logging", enabled) - configure_logging(enabled) - -def highlight_problem_bones(self: Any, context: Context) -> None: - """Log when problem bones are highlighted""" - from .addon_preferences import save_preference - enabled = self.highlight_problem_bones - save_preference("highlight_problem_bones", enabled) - logger.debug(f"Problem bone highlighting {'enabled' if enabled else 'disabled'}") + configure_logging(enabled, level) diff --git a/core/mmd/__init__.py b/core/mmd/__init__.py new file mode 100644 index 0000000..af8a62d --- /dev/null +++ b/core/mmd/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import os +import tomllib + +# This is a temporary workaround i be changing how MMD Tools works later when it comes to getting version number. + +try: + + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.dirname(os.path.dirname(current_dir)) + manifest_path = os.path.join(root_dir, 'blender_manifest.toml') + + if os.path.exists(manifest_path): + with open(manifest_path, 'rb') as f: + manifest = tomllib.load(f) + AVATAR_TOOLKIT_VERSION = manifest.get('version', '0.2.1') + else: + AVATAR_TOOLKIT_VERSION = '0.2.1' +except Exception: + AVATAR_TOOLKIT_VERSION = '0.2.1' \ No newline at end of file diff --git a/core/mmd/bpyutils.py b/core/mmd/bpyutils.py new file mode 100644 index 0000000..c5c9d76 --- /dev/null +++ b/core/mmd/bpyutils.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import contextlib +from typing import Generator, List, Optional, TypeVar + +import bpy + + +class Props: # For API changes of only name changed properties + show_in_front = "show_in_front" + display_type = "display_type" + display_size = "display_size" + empty_display_type = "empty_display_type" + empty_display_size = "empty_display_size" + + +class __EditMode: + def __init__(self, obj): + if not isinstance(obj, bpy.types.Object): + raise ValueError + self.__prevMode = obj.mode + self.__obj = obj + self.__obj_select = obj.select_get() + with select_object(obj): + if obj.mode != "EDIT": + bpy.ops.object.mode_set(mode="EDIT") + + def __enter__(self): + return self.__obj.data + + def __exit__(self, type, value, traceback): + if self.__prevMode == "EDIT": + bpy.ops.object.mode_set(mode="OBJECT") # update edited data + bpy.ops.object.mode_set(mode=self.__prevMode) + self.__obj.select_set(self.__obj_select) + + +class __SelectObjects: + def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None): + if not isinstance(active_object, bpy.types.Object): + raise ValueError + try: + bpy.ops.object.mode_set(mode="OBJECT") + except Exception: + pass + + contenxt = FnContext.ensure_context() + + for i in contenxt.selected_objects: + i.select_set(False) + + self.__active_object = active_object + self.__selected_objects = tuple(set(selected_objects) | set([active_object])) if selected_objects else (active_object,) + + self.__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) + + def __enter__(self) -> bpy.types.Object: + return self.__active_object + + def __exit__(self, type, value, traceback): + for i, j in zip(self.__selected_objects, self.__hides): + i.hide_set(j) + + +def setParent(obj, parent): + with select_object(parent, objects=[parent, obj]): + bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False) + + +def setParentToBone(obj, parent, bone_name): + with select_object(parent, objects=[parent, obj]): + bpy.ops.object.mode_set(mode="POSE") + parent.data.bones.active = parent.data.bones[bone_name] + bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False) + bpy.ops.object.mode_set(mode="OBJECT") + + +def edit_object(obj): + """Set the object interaction mode to 'EDIT' + + It is recommended to use 'edit_object' with 'with' statement like the following code. + + with edit_object: + some functions... + """ + return __EditMode(obj) + + +def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None): + """Select objects. + + It is recommended to use 'select_object' with 'with' statement like the following code. + This function can select "hidden" objects safely. + + with select_object(obj): + some functions... + """ + # TODO: Reimplement with bpy.context.temp_override (If it ain't broke, don't fix it.) + return __SelectObjects(obj, objects) + + +def duplicateObject(obj, total_len): + return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len) + + +def createObject(name="Object", object_data=None, target_scene=None): + context = FnContext.ensure_context(target_scene) + return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data)) + + +def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None): + import bmesh + + if target_object is None: + target_object = createObject(name="Sphere") + + mesh = target_object.data + bm = bmesh.new() + bmesh.ops.create_uvsphere( + bm, + u_segments=segment, + v_segments=ring_count, + radius=radius, + ) + for f in bm.faces: + f.smooth = True + bm.to_mesh(mesh) + bm.free() + return target_object + + +def makeBox(size=(1, 1, 1), target_object=None): + import bmesh + from mathutils import Matrix + + if target_object is None: + target_object = createObject(name="Box") + + mesh = target_object.data + bm = bmesh.new() + bmesh.ops.create_cube( + bm, + size=2, + matrix=Matrix([[size[0], 0, 0, 0], [0, size[1], 0, 0], [0, 0, size[2], 0], [0, 0, 0, 1]]), + ) + for f in bm.faces: + f.smooth = True + bm.to_mesh(mesh) + bm.free() + return target_object + + +def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None): + import math + + import bmesh + + if target_object is None: + target_object = createObject(name="Capsule") + height = max(height, 1e-3) + + mesh = target_object.data + bm = bmesh.new() + verts = bm.verts + top = (0, 0, height / 2 + radius) + verts.new(top) + + # f = lambda i: radius*i/ring_count + f = lambda i: radius * math.sin(0.5 * math.pi * i / ring_count) + for i in range(ring_count, 0, -1): + z = f(i - 1) + t = math.sqrt(radius**2 - z**2) + for j in range(segment): + theta = 2 * math.pi / segment * j + x = t * math.sin(-theta) + y = t * math.cos(-theta) + verts.new((x, y, z + height / 2)) + + for i in range(ring_count): + z = -f(i) + t = math.sqrt(radius**2 - z**2) + for j in range(segment): + theta = 2 * math.pi / segment * j + x = t * math.sin(-theta) + y = t * math.cos(-theta) + verts.new((x, y, z - height / 2)) + + bottom = (0, 0, -(height / 2 + radius)) + verts.new(bottom) + if hasattr(verts, "ensure_lookup_table"): + verts.ensure_lookup_table() + + faces = bm.faces + for i in range(1, segment): + faces.new([verts[x] for x in (0, i, i + 1)]) + faces.new([verts[x] for x in (0, segment, 1)]) + offset = segment + 1 + for i in range(ring_count * 2 - 1): + for j in range(segment - 1): + t = offset + j + faces.new([verts[x] for x in (t - segment, t, t + 1, t - segment + 1)]) + faces.new([verts[x] for x in (offset - 1, offset + segment - 1, offset, offset - segment)]) + offset += segment + for i in range(segment - 1): + t = offset + i + faces.new([verts[x] for x in (t - segment, offset, t - segment + 1)]) + faces.new([verts[x] for x in (offset - 1, offset, offset - segment)]) + + for f in bm.faces: + f.smooth = True + bm.normal_update() + bm.to_mesh(mesh) + bm.free() + return target_object + + +class TransformConstraintOp: + __MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"} + + @staticmethod + def create(constraints, name, map_type): + c = constraints.get(name, None) + if c and c.type != "TRANSFORM": + constraints.remove(c) + c = None + if c is None: + c = constraints.new("TRANSFORM") + c.name = name + c.use_motion_extrapolate = True + c.target_space = c.owner_space = "LOCAL" + c.map_from = c.map_to = map_type + c.map_to_x_from = "X" + c.map_to_y_from = "Y" + c.map_to_z_from = "Z" + c.influence = 1 + return c + + @classmethod + def min_max_attributes(cls, map_type, name_id=""): + key = (map_type, name_id) + ret = cls.__MIN_MAX_MAP.get(key, None) + if ret is None: + defaults = (i + j + k for i in ("from_", "to_") for j in ("min_", "max_") for k in "xyz") + extension = cls.__MIN_MAX_MAP.get(map_type, "") + ret = cls.__MIN_MAX_MAP[key] = tuple(n + extension for n in defaults if name_id in n) + return ret + + @classmethod + def update_min_max(cls, constraint, value, influence=1): + c = constraint + if not c or c.type != "TRANSFORM": + return + + for attr in cls.min_max_attributes(c.map_from, "from_min"): + setattr(c, attr, -value) + for attr in cls.min_max_attributes(c.map_from, "from_max"): + setattr(c, attr, value) + + if influence is None: + return + + for attr in cls.min_max_attributes(c.map_to, "to_min"): + setattr(c, attr, -value * influence) + for attr in cls.min_max_attributes(c.map_to, "to_max"): + setattr(c, attr, value * influence) + + +class FnObject: + def __init__(self): + 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): + assert isinstance(mesh_object.data, bpy.types.Mesh) + + key: bpy.types.Key = shape_key.id_data + assert key == mesh_object.data.shape_keys + + if mesh_object.animation_data is not None: + fc_curve: bpy.types.FCurve + for fc_curve in mesh_object.animation_data.drivers: + if not fc_curve.data_path.startswith(shape_key.path_from_id()): + continue + mesh_object.driver_remove(fc_curve.data_path) + + key_blocks = key.key_blocks + + last_index = mesh_object.active_shape_key_index or 0 + if last_index >= key_blocks.find(shape_key.name): + last_index = max(0, last_index - 1) + + mesh_object.shape_key_remove(shape_key) + mesh_object.active_shape_key_index = min(last_index, len(key_blocks) - 1) + + +ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = TypeVar("ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE") + + +class FnContext: + def __init__(self): + raise NotImplementedError("This class is not expected to be instantiated.") + + @staticmethod + def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context: + return context or bpy.context + + @staticmethod + def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]: + return context.active_object + + @staticmethod + def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + context.view_layer.objects.active = obj + return obj + + @staticmethod + def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + return FnContext.set_active_object(context, FnContext.select_single_object(context, obj)) + + @staticmethod + def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects: + return context.scene.objects + + @staticmethod + def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + obj.hide_viewport = False + obj.hide_select = False + obj.hide_set(False) + + if obj not in context.selectable_objects: + + def __layer_check(layer_collection: bpy.types.LayerCollection) -> bool: + for lc in layer_collection.children: + if __layer_check(lc): + lc.hide_viewport = False + lc.collection.hide_viewport = False + lc.collection.hide_select = False + return True + if obj in layer_collection.collection.objects.values(): + if layer_collection.exclude: + layer_collection.exclude = False + return True + return False + + selected_objects = set(context.selected_objects) + __layer_check(context.view_layer.layer_collection) + if len(context.selected_objects) != len(selected_objects): + for i in context.selected_objects: + if i not in selected_objects: + i.select_set(False) + return obj + + @staticmethod + def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + FnContext.ensure_selectable(context, obj).select_set(True) + return obj + + @staticmethod + def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]: + return [FnContext.select_object(context, obj) for obj in objects] + + @staticmethod + def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + for i in context.selected_objects: + if i != obj: + i.select_set(False) + return FnContext.select_object(context, obj) + + @staticmethod + def link_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.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: + 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]: + """ + 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. + target_count (int): The desired count of duplicated objects. + + Returns: + List[bpy.types.Object]: A list of duplicated objects. + + Raises: + AssertionError: If the number of selected objects in the context is not equal to 1 or if the selected object is not the same as the object to be duplicated. + """ + for o in context.selected_objects: + o.select_set(False) + object_to_duplicate.select_set(True) + assert len(context.selected_objects) == 1 + assert context.selected_objects[0] == object_to_duplicate + last_selected_objects = result_objects = [object_to_duplicate] + while len(result_objects) < target_count: + bpy.ops.object.duplicate() + result_objects.extend(context.selected_objects) + remain = target_count - len(result_objects) - len(context.selected_objects) + if remain < 0: + last_selected_objects = context.selected_objects + for i in range(-remain): + last_selected_objects[i].select_set(False) + else: + for i in range(min(remain, len(last_selected_objects))): + last_selected_objects[i].select_set(True) + last_selected_objects = context.selected_objects + assert len(result_objects) == target_count + return result_objects + + @staticmethod + def find_user_layer_collection_by_object(context: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]: + """ + Finds the layer collection that contains the given target_object in the user's collections. + + Args: + context (bpy.types.Context): The Blender context. + target_object (bpy.types.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. + """ + scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection + + def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]: + if layer_collection.name == name: + return layer_collection + + child_layer_collection: bpy.types.LayerCollection + for child_layer_collection in layer_collection.children: + found = find_layer_collection_by_name(child_layer_collection, name) + if found is not None: + return found + + return None + + user_collection: bpy.types.Collection + for user_collection in target_object.users_collection: + found = find_layer_collection_by_name(scene_layer_collection, user_collection.name) + if found is not None: + return found + + return None + + @staticmethod + @contextlib.contextmanager + def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]: + """ + Context manager to temporarily override the active_layer_collection that contains the target object. + + This context manager allows you to temporarily change the active_layer_collection in the given context to the one that contains the target object. + 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. + + Yields: + bpy.types.Context: The modified context with the active_layer_collection overridden. + + Example: + with FnContext.temp_override_active_layer_collection(context, target_object): + # Perform operations with the modified context + bpy.ops.object.select_all(action='DESELECT') + target_object.select_set(True) + bpy.ops.object.delete() + + """ + original_layer_collection = context.view_layer.active_layer_collection + target_layer_collection = FnContext.find_user_layer_collection_by_object(context, target_object) + if target_layer_collection is not None: + context.view_layer.active_layer_collection = target_layer_collection + try: + yield context + finally: + if context.view_layer.active_layer_collection.name != original_layer_collection.name: + 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) + 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: + 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]: + if active_object is not None: + keywords["active_object"] = active_object + keywords["object"] = active_object + + if selected_objects is not None: + keywords["selected_objects"] = selected_objects + keywords["selected_editable_objects"] = selected_objects + + return context.temp_override(window=window, area=area, region=region, **keywords) diff --git a/core/mmd/core/__init__.py b/core/mmd/core/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/core/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/core/bone.py b/core/mmd/core/bone.py new file mode 100644 index 0000000..73fa58c --- /dev/null +++ b/core/mmd/core/bone.py @@ -0,0 +1,564 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import math +from typing import TYPE_CHECKING, Iterable, Optional, Set + +import bpy +from mathutils import Vector + +from .. import bpyutils +from ..bpyutils import TransformConstraintOp +from ..utils import ItemOp + +if TYPE_CHECKING: + from ..properties.root import MMDRoot, MMDDisplayItemFrame + from ..properties.pose_bone import MMDBone + + +def remove_constraint(constraints, name): + c = constraints.get(name, None) + if c: + constraints.remove(c) + return True + return False + + +def remove_edit_bones(edit_bones, bone_names): + for name in bone_names: + b = edit_bones.get(name, None) + if b: + edit_bones.remove(b) + + +BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection" +BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection" +BONE_COLLECTION_NAME_SHADOW = "mmd_shadow" +BONE_COLLECTION_NAME_DUMMY = "mmd_dummy" + +SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NAME_DUMMY] + + +class FnBone: + AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") + AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指") + AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") + + def __init__(self): + 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]: + for bone in armature_object.pose.bones: + if bone.mmd_bone.bone_id != bone_id: + continue + return bone + return None + + @staticmethod + def __new_bone_id(armature_object: bpy.types.Object) -> int: + return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1 + + @staticmethod + def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int: + if pose_bone.mmd_bone.bone_id < 0: + pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data) + return pose_bone.mmd_bone.bone_id + + @staticmethod + def __get_selected_pose_bones(armature_object: bpy.types.Object) -> Iterable[bpy.types.PoseBone]: + if armature_object.mode == "EDIT": + bpy.ops.object.mode_set(mode="OBJECT") # update selected bones + bpy.ops.object.mode_set(mode="EDIT") # back to edit mode + context_selected_bones = bpy.context.selected_pose_bones or bpy.context.selected_bones or [] + bones = armature_object.pose.bones + return (bones[b.name] for b in context_selected_bones if not bones[b.name].is_mmd_shadow_bone) + + @staticmethod + def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True): + for b in FnBone.__get_selected_pose_bones(armature_object): + mmd_bone: MMDBone = b.mmd_bone + mmd_bone.enabled_fixed_axis = enable + lock_rotation = b.lock_rotation[:] + if enable: + axes = b.bone.matrix_local.to_3x3().transposed() + if lock_rotation.count(False) == 1: + mmd_bone.fixed_axis = axes[lock_rotation.index(False)].xzy + else: + mmd_bone.fixed_axis = axes[1].xzy # Y-axis + elif all(b.lock_location) and lock_rotation.count(True) > 1 and lock_rotation == (b.lock_ik_x, b.lock_ik_y, b.lock_ik_z): + # unlock transform locks if fixed axis was applied + b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = (False, False, False) + 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 + 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) + return armature_object + + @staticmethod + def __is_mmd_tools_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection + + @staticmethod + def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + 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): + 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: + 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): + 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: + 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: + 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: + 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: + for bone_collection in edit_bone.collections: + if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + continue + bone_collection.unassign(edit_bone) + return edit_bone + + @staticmethod + def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object): + armature: bpy.types.Armature = armature_object.data + bone_collections = armature.collections + + from .model import FnModel + + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root + + bones = armature.bones + used_groups = set() + unassigned_bone_names = {b.name for b in bones} + + for frame in mmd_root.display_item_frames: + for item in frame.data: + if item.type == "BONE" and item.name in unassigned_bone_names: + unassigned_bone_names.remove(item.name) + group_name = frame.name + used_groups.add(group_name) + bone_collection = bone_collections.get(group_name) + if bone_collection is None: + bone_collection = bone_collections.new(name=group_name) + FnBone.__set_bone_collection_to_normal(bone_collection) + bone_collection.assign(bones[item.name]) + + for name in unassigned_bone_names: + for bc in bones[name].collections: + if not FnBone.__is_mmd_tools_bone_collection(bc): + continue + if not FnBone.__is_normal_bone_collection(bc): + continue + bc.unassign(bones[name]) + + # remove unused bone groups + for bone_collection in bone_collections.values(): + if bone_collection.name in used_groups: + continue + if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + continue + if not FnBone.__is_normal_bone_collection(bone_collection): + continue + 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 + + from .model import FnModel + + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root + display_item_frames = mmd_root.display_item_frames + + used_frame_index: Set[int] = set() + + bone_collection: bpy.types.BoneCollection + for bone_collection in bone_collections: + if len(bone_collection.bones) == 0 or FnBone.__is_special_bone_collection(bone_collection): + continue + + bone_collection_name = bone_collection.name + display_item_frame: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name) + if display_item_frame is None: + display_item_frame = display_item_frames.add() + display_item_frame.name = bone_collection_name + display_item_frame.name_e = bone_collection_name + used_frame_index.add(display_item_frames.find(bone_collection_name)) + + ItemOp.resize(display_item_frame.data, len(bone_collection.bones)) + for display_item, bone in zip(display_item_frame.data, bone_collection.bones): + display_item.type = "BONE" + display_item.name = bone.name + + for i in reversed(range(len(display_item_frames))): + if i in used_frame_index: + continue + display_item_frame = display_item_frames[i] + if display_item_frame.is_special: + if display_item_frame.name != "表情": + display_item_frame.data.clear() + else: + 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 = {} + 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 + 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 + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False + continue + fixed_axis, is_tip, parent_tip = bone_map[bone.name] + if fixed_axis.length: + axes = [bone.x_axis, bone.y_axis, bone.z_axis] + direction = fixed_axis.normalized().xzy + idx, val = max([(i, direction.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) + idx_1, idx_2 = (idx + 1) % 3, (idx + 2) % 3 + axes[idx] = -direction if val < 0 else direction + axes[idx_2] = axes[idx].cross(axes[idx_1]) + axes[idx_1] = axes[idx_2].cross(axes[idx]) + if parent_tip and bone.use_connect: + bone.use_connect = False + bone.head = bone.parent.head + if force_align: + tail = bone.head + axes[1].normalized() * bone.length + if is_tip or (tail - bone.tail).length > 1e-4: + for c in bone.children: + if c.use_connect: + c.use_connect = False + if is_tip: + c.head = bone.head + bone.tail = tail + bone.align_roll(axes[2]) + bone_map[bone.name] = tuple(i != idx for i in range(3)) + else: + bone_map[bone.name] = (True, True, True) + bone.select = True + + for bone_name, locks in bone_map.items(): + b = armature_object.pose.bones[bone_name] + b.lock_location = (True, True, True) + 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): + for b in FnBone.__get_selected_pose_bones(armature_object): + mmd_bone: MMDBone = b.mmd_bone + mmd_bone.enabled_local_axes = enable + if enable: + axes = b.bone.matrix_local.to_3x3().transposed() + mmd_bone.local_axis_x = axes[0].xzy + mmd_bone.local_axis_z = axes[2].xzy + + @staticmethod + def apply_bone_local_axes(armature_object: bpy.types.Object): + bone_map = {} + for b in armature_object.pose.bones: + if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: + continue + mmd_bone: MMDBone = b.mmd_bone + bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z) + + with bpyutils.edit_object(armature_object) as data: + bone: bpy.types.EditBone + for bone in data.edit_bones: + if bone.name not in bone_map: + bone.select = False + continue + local_axis_x, local_axis_z = bone_map[bone.name] + FnBone.update_bone_roll(bone, local_axis_x, local_axis_z) + bone.select = True + + @staticmethod + def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z): + axes = FnBone.get_axes(mmd_local_axis_x, mmd_local_axis_z) + idx, val = max([(i, edit_bone.vector.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) + edit_bone.align_roll(axes[(idx - 1) % 3 if val < 0 else (idx + 1) % 3]) + + @staticmethod + def get_axes(mmd_local_axis_x, mmd_local_axis_z): + x_axis = Vector(mmd_local_axis_x).normalized().xzy + z_axis = Vector(mmd_local_axis_z).normalized().xzy + y_axis = z_axis.cross(x_axis).normalized() + z_axis = x_axis.cross(y_axis).normalized() # correction + return (x_axis, y_axis, z_axis) + + @staticmethod + def apply_auto_bone_roll(armature): + bone_names = [] + 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 + for bone in data.edit_bones: + if bone.name not in bone_names: + continue + FnBone.update_auto_bone_roll(bone) + bone.select = True + + @staticmethod + def update_auto_bone_roll(edit_bone): + # make a triangle face (p1,p2,p3) + p1 = edit_bone.head.copy() + p2 = edit_bone.tail.copy() + p3 = p2.copy() + # translate p3 in xz plane + # the normal vector of the face tracks -Y direction + xz = Vector((p2.x - p1.x, p2.z - p1.z)) + xz.normalize() + theta = math.atan2(xz.y, xz.x) + norm = edit_bone.vector.length + p3.z += norm * math.cos(theta) + p3.x -= norm * math.sin(theta) + # calculate the normal vector of the face + y = (p2 - p1).normalized() + z_tmp = (p3 - p1).normalized() + x = y.cross(z_tmp) # normal vector + # z = x.cross(y) + FnBone.update_bone_roll(edit_bone, y.xzy, x.xzy) + + @staticmethod + def has_auto_local_axis(name_j): + if name_j: + if name_j in FnBone.AUTO_LOCAL_AXIS_ARMS or name_j in FnBone.AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: + return True + for finger_name in FnBone.AUTO_LOCAL_AXIS_FINGERS: + if finger_name in name_j: + return True + return False + + @staticmethod + def clean_additional_transformation(armature_object: bpy.types.Object): + # clean constraints + p_bone: bpy.types.PoseBone + for p_bone in armature_object.pose.bones: + p_bone.mmd_bone.is_additional_transform_dirty = True + constraints = p_bone.constraints + remove_constraint(constraints, "mmd_additional_rotation") + remove_constraint(constraints, "mmd_additional_location") + if remove_constraint(constraints, "mmd_additional_parent"): + p_bone.bone.use_inherit_rotation = True + # clean shadow bones + shadow_bone_types = { + "DUMMY", + "SHADOW", + "ADDITIONAL_TRANSFORM", + "ADDITIONAL_TRANSFORM_INVERT", + } + + def __is_at_shadow_bone(b): + return b.is_mmd_shadow_bone and b.mmd_shadow_bone_type in shadow_bone_types + + shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)] + if len(shadow_bone_names) > 0: + 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): + if b.is_mmd_shadow_bone: + return False + mmd_bone = b.mmd_bone + if mmd_bone.has_additional_rotation or mmd_bone.has_additional_location: + return True + return mmd_bone.is_additional_transform_dirty + + dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)] + + # setup constraints + shadow_bone_pool = [] + for p_bone in dirty_bones: + sb = FnBone.__setup_constraints(p_bone) + if sb: + shadow_bone_pool.append(sb) + + # setup shadow bones + with bpyutils.edit_object(armature_object) as data: + edit_bones = data.edit_bones + for sb in shadow_bone_pool: + sb.update_edit_bones(edit_bones) + + pose_bones = armature_object.pose.bones + for sb in shadow_bone_pool: + sb.update_pose_bones(pose_bones) + + # finish + for p_bone in dirty_bones: + p_bone.mmd_bone.is_additional_transform_dirty = False + + @staticmethod + def __setup_constraints(p_bone): + bone_name = p_bone.name + mmd_bone = p_bone.mmd_bone + influence = mmd_bone.additional_transform_influence + target_bone = mmd_bone.additional_transform_bone + mute_rotation = not mmd_bone.has_additional_rotation # or p_bone.is_in_ik_chain + mute_location = not mmd_bone.has_additional_location + + constraints = p_bone.constraints + if not target_bone or (mute_rotation and mute_location) or influence == 0: + rot = remove_constraint(constraints, "mmd_additional_rotation") + loc = remove_constraint(constraints, "mmd_additional_location") + if rot or loc: + return _AT_ShadowBoneRemove(bone_name) + return None + + shadow_bone = _AT_ShadowBoneCreate(bone_name, target_bone) + + def __config(name, mute, map_type, value): + if mute: + remove_constraint(constraints, name) + return + c = TransformConstraintOp.create(constraints, name, map_type) + c.target = p_bone.id_data + shadow_bone.add_constraint(c) + TransformConstraintOp.update_min_max(c, value, influence) + + __config("mmd_additional_rotation", mute_rotation, "ROTATION", math.pi) + __config("mmd_additional_location", mute_location, "LOCATION", 100) + + return shadow_bone + + @staticmethod + def update_additional_transform_influence(pose_bone: bpy.types.PoseBone): + influence = pose_bone.mmd_bone.additional_transform_influence + constraints = pose_bone.constraints + c = constraints.get("mmd_additional_rotation", None) + TransformConstraintOp.update_min_max(c, math.pi, influence) + c = constraints.get("mmd_additional_location", None) + TransformConstraintOp.update_min_max(c, 100, influence) + + +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 + for pose_bone in armature_object.pose.bones: + constraint: bpy.types.Constraint + for constraint in pose_bone.constraints: + if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name: + constraint.owner_space = "LOCAL" + + +class _AT_ShadowBoneRemove: + def __init__(self, bone_name): + self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name) + + def update_edit_bones(self, edit_bones): + remove_edit_bones(edit_bones, self.__shadow_bone_names) + + def update_pose_bones(self, pose_bones): + pass + + +class _AT_ShadowBoneCreate: + def __init__(self, bone_name, target_bone_name): + self.__dummy_bone_name = "_dummy_" + bone_name + self.__shadow_bone_name = "_shadow_" + bone_name + self.__bone_name = bone_name + self.__target_bone_name = target_bone_name + self.__constraint_pool = [] + + def __is_well_aligned(self, bone0, bone1): + return bone0.x_axis.dot(bone1.x_axis) > 0.99 and bone0.y_axis.dot(bone1.y_axis) > 0.99 + + def __update_constraints(self, use_shadow=True): + subtarget = self.__shadow_bone_name if use_shadow else self.__target_bone_name + for c in self.__constraint_pool: + c.subtarget = subtarget + + def add_constraint(self, constraint): + self.__constraint_pool.append(constraint) + + def update_edit_bones(self, edit_bones): + bone = edit_bones[self.__bone_name] + target_bone = edit_bones[self.__target_bone_name] + if bone != target_bone and self.__is_well_aligned(bone, target_bone): + _AT_ShadowBoneRemove(self.__bone_name).update_edit_bones(edit_bones) + return + + dummy_bone_name = self.__dummy_bone_name + dummy = edit_bones.get(dummy_bone_name, None) or FnBone.set_edit_bone_to_dummy(edit_bones.new(name=dummy_bone_name)) + dummy.parent = target_bone + dummy.head = target_bone.head + dummy.tail = dummy.head + bone.tail - bone.head + dummy.roll = bone.roll + + 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)) + shadow.parent = target_bone.parent + shadow.head = dummy.head + shadow.tail = dummy.tail + shadow.roll = bone.roll + + def update_pose_bones(self, pose_bones): + if self.__shadow_bone_name not in pose_bones: + 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" + + shadow_p_bone = pose_bones[self.__shadow_bone_name] + shadow_p_bone.is_mmd_shadow_bone = True + shadow_p_bone.mmd_shadow_bone_type = "SHADOW" + + if "mmd_tools_at_dummy" not in shadow_p_bone.constraints: + c = shadow_p_bone.constraints.new("COPY_TRANSFORMS") + c.name = "mmd_tools_at_dummy" + c.target = dummy_p_bone.id_data + c.subtarget = dummy_p_bone.name + c.target_space = "POSE" + c.owner_space = "POSE" + + self.__update_constraints() diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py new file mode 100644 index 0000000..9c5b2bd --- /dev/null +++ b/core/mmd/core/camera.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import math +from typing import Optional + +import bpy + +from ..bpyutils import FnContext, Props + + +class FnCamera: + @staticmethod + def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]: + if obj is None: + return None + if FnCamera.is_mmd_camera_root(obj): + return obj + if obj.parent is not None and FnCamera.is_mmd_camera_root(obj.parent): + return obj.parent + return None + + @staticmethod + def is_mmd_camera(obj: bpy.types.Object) -> bool: + return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None + + @staticmethod + def is_mmd_camera_root(obj: bpy.types.Object) -> bool: + 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): + d = id_data.driver_add(data_path, index).driver + d.type = "SCRIPTED" + if "$empty_distance" in expression: + v = d.variables.new() + v.name = "empty_distance" + v.type = "TRANSFORMS" + v.targets[0].id = camera_object + v.targets[0].transform_type = "LOC_Y" + v.targets[0].transform_space = "LOCAL_SPACE" + expression = expression.replace("$empty_distance", v.name) + if "$is_perspective" in expression: + v = d.variables.new() + v.name = "is_perspective" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "OBJECT" + v.targets[0].id = camera_object.parent + v.targets[0].data_path = "mmd_camera.is_perspective" + expression = expression.replace("$is_perspective", v.name) + if "$angle" in expression: + v = d.variables.new() + v.name = "angle" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "OBJECT" + v.targets[0].id = camera_object.parent + v.targets[0].data_path = "mmd_camera.angle" + expression = expression.replace("$angle", v.name) + if "$sensor_height" in expression: + v = d.variables.new() + v.name = "sensor_height" + v.type = "SINGLE_PROP" + v.targets[0].id_type = "CAMERA" + v.targets[0].id = camera_object.data + v.targets[0].data_path = "sensor_height" + expression = expression.replace("$sensor_height", v.name) + + 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") + + @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") + + +class MigrationFnCamera: + @staticmethod + def update_mmd_camera(): + for camera_object in bpy.data.objects: + if camera_object.type != "CAMERA": + continue + + root_object = FnCamera.find_root(camera_object) + if root_object is None: + # It's not a MMD Camera + continue + + FnCamera.remove_drivers(camera_object) + FnCamera.add_drivers(camera_object) + + +class MMDCamera: + def __init__(self, obj): + root_object = FnCamera.find_root(obj) + if root_object is None: + raise ValueError("%s is not MMDCamera" % str(obj)) + + self.__emptyObj = getattr(root_object, "original", obj) + + @staticmethod + def isMMDCamera(obj: bpy.types.Object) -> bool: + return FnCamera.find_root(obj) is not None + + @staticmethod + def addDrivers(cameraObj: bpy.types.Object): + FnCamera.add_drivers(cameraObj) + + @staticmethod + def removeDrivers(cameraObj: bpy.types.Object): + if cameraObj.type != "CAMERA": + return + FnCamera.remove_drivers(cameraObj) + + @staticmethod + def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0): + if FnCamera.is_mmd_camera(cameraObj): + return MMDCamera(cameraObj) + + empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) + + cameraObj.parent = empty + cameraObj.data.sensor_fit = "VERTICAL" + cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV + cameraObj.data.ortho_scale = 25 * scale + cameraObj.data.clip_end = 500 * scale + setattr(cameraObj.data, Props.display_size, 5 * scale) + cameraObj.location = (0, -45 * scale, 0) + cameraObj.rotation_mode = "XYZ" + cameraObj.rotation_euler = (math.radians(90), 0, 0) + cameraObj.lock_location = (True, False, True) + cameraObj.lock_rotation = (True, True, True) + cameraObj.lock_scale = (True, True, True) + cameraObj.data.dof.focus_object = empty + FnCamera.add_drivers(cameraObj) + + empty.location = (0, 0, 10 * scale) + empty.rotation_mode = "YXZ" + setattr(empty, Props.empty_display_size, 5 * scale) + empty.lock_scale = (True, True, True) + empty.mmd_type = "CAMERA" + empty.mmd_camera.angle = math.radians(30) + empty.mmd_camera.persp = True + return MMDCamera(empty) + + @staticmethod + def newMMDCameraAnimation(cameraObj, cameraTarget=None, scale=1.0, min_distance=0.1): + scene = bpy.context.scene + mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera")) + FnContext.link_object(FnContext.ensure_context(), mmd_cam) + MMDCamera.convertToMMDCamera(mmd_cam, scale=scale) + mmd_cam_root = mmd_cam.parent + + _camera_override_func = 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 + + _target_override_func = 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) + + from math import atan + + from mathutils import Matrix, Vector + + render = scene.render + factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x) + matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1])) + neg_z_vector = Vector((0, 0, -1)) + frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current + frame_count = frame_end - frame_start + frames = range(frame_start, frame_end) + + 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) + + for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves)): + scene.frame_set(f) + if _camera_override_func: + cameraObj = _camera_override_func() + if _target_override_func: + cameraTarget = _target_override_func(cameraObj) + cam_matrix_world = cameraObj.matrix_world + cam_target_loc = cameraTarget.matrix_world.translation + cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode) + cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector + if cameraObj.data.type == "ORTHO": + cam_dis = -(9 / 5) * cameraObj.data.ortho_scale + if cameraObj.data.sensor_fit != "VERTICAL": + if cameraObj.data.sensor_fit == "HORIZONTAL": + cam_dis *= factor + else: + cam_dis *= min(1, factor) + else: + target_vec = cam_target_loc - cam_matrix_world.translation + cam_dis = -max(target_vec.length * cam_vec.dot(target_vec.normalized()), min_distance) + cam_target_loc = cam_matrix_world.translation - cam_vec * cam_dis + + tan_val = cameraObj.data.sensor_height / cameraObj.data.lens / 2 + 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" + + FnCamera.add_drivers(mmd_cam) + mmd_cam_root.animation_data_create().action = parent_action + mmd_cam.animation_data_create().action = distance_action + scene.frame_set(frame_current) + return MMDCamera(mmd_cam_root) + + def object(self): + return self.__emptyObj + + def camera(self): + for i in self.__emptyObj.children: + if i.type == "CAMERA": + return i + raise KeyError diff --git a/core/mmd/core/exceptions.py b/core/mmd/core/exceptions.py new file mode 100644 index 0000000..c89366a --- /dev/null +++ b/core/mmd/core/exceptions.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + + +class MaterialNotFoundError(KeyError): + """Exception raised when a material is not found in the scene""" + + def __init__(self, *args: object) -> None: + """Constructor for MaterialNotFoundError""" + super().__init__(*args) diff --git a/core/mmd/core/lamp.py b/core/mmd/core/lamp.py new file mode 100644 index 0000000..549a83b --- /dev/null +++ b/core/mmd/core/lamp.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +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/core/material.py b/core/mmd/core/material.py new file mode 100644 index 0000000..68fba09 --- /dev/null +++ b/core/mmd/core/material.py @@ -0,0 +1,718 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import os +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast + +import bpy +from mathutils import Vector + +from ..bpyutils import FnContext +from .exceptions import MaterialNotFoundError +from .shader import _NodeGroupUtils + +if TYPE_CHECKING: + from ..properties.material import MMDMaterial + +# TODO: use enum instead of constants +SPHERE_MODE_OFF = 0 +SPHERE_MODE_MULT = 1 +SPHERE_MODE_ADD = 2 +SPHERE_MODE_SUBTEX = 3 + + +class _DummyTexture: + def __init__(self, image): + self.type = "IMAGE" + self.image = image + self.use_mipmap = True + + +class _DummyTextureSlot: + def __init__(self, image): + self.diffuse_color_factor = 1 + self.uv_layer = "" + self.texture = _DummyTexture(image) + + +class FnMaterial: + __NODES_ARE_READONLY: bool = False + + def __init__(self, material: bpy.types.Material): + self.__material = material + self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY + + @staticmethod + def set_nodes_are_readonly(nodes_are_readonly: bool): + FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly + + @classmethod + def from_material_id(cls, material_id: str): + 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]): + materials = obj.data.materials + materials_pop = materials.pop + for i in sorted((x for x, m in enumerate(materials) if can_remove(m)), reverse=True): + m = materials_pop(index=i) + if m.users < 1: + bpy.data.materials.remove(m) + + @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]: + """ + 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. + The reference to materials can be indexes or names + Finally it will also swap the material slots if the option is given. + + Args: + mesh_object (bpy.types.Object): The mesh object + mat1_ref (str | int): The reference to the first material + mat2_ref (str | int): The reference to the second material + reverse (bool, optional): If true it will also swap the polygons assigned to mat2 to mat1. Defaults to False. + swap_slots (bool, optional): If true it will also swap the material slots. Defaults to False. + + Retruns: + Tuple[bpy.types.Material, bpy.types.Material]: The swapped materials + + Raises: + MaterialNotFoundError: If one of the materials is not found + """ + mesh = cast(bpy.types.Mesh, mesh_object.data) + try: + # Try to find the materials + mat1 = mesh.materials[mat1_ref] + mat2 = mesh.materials[mat2_ref] + if None in (mat1, mat2): + raise MaterialNotFoundError() + 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) + # Swap polygons + for poly in mesh.polygons: + if poly.material_index == mat1_idx: + poly.material_index = mat2_idx + elif reverse and poly.material_index == mat2_idx: + poly.material_index = mat1_idx + # Swap slots if specified + if swap_slots: + mesh_object.material_slots[mat1_idx].material = mat2 + mesh_object.material_slots[mat2_idx].material = mat1 + return mat1, mat2 + + @staticmethod + def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]): + """ + This method will fix the material order. Which is lost after joining meshes. + """ + materials = cast(bpy.types.Mesh, meshObj.data).materials + for new_idx, mat in enumerate(material_names): + # Get the material that is currently on this index + other_mat = materials[new_idx] + if other_mat.name == mat: + continue # This is already in place + FnMaterial.swap_materials(meshObj, mat, new_idx, reverse=True, swap_slots=True) + + @property + def material_id(self): + mmd_mat: MMDMaterial = self.__material.mmd_material + if mmd_mat.material_id < 0: + max_id = -1 + for mat in bpy.data.materials: + max_id = max(max_id, mat.mmd_material.material_id) + mmd_mat.material_id = max_id + 1 + return mmd_mat.material_id + + @property + def material(self): + return self.__material + + def __same_image_file(self, image, filepath): + if image and image.source == "FILE": + # pylint: disable=assignment-from-no-return + img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user() + if img_filepath == filepath: + return True + # pylint: disable=bare-except + try: + return os.path.samefile(img_filepath, filepath) + except: + pass + return False + + def _load_image(self, filepath): + img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None) + if img is None: + # pylint: disable=bare-except + try: + img = bpy.data.images.load(filepath) + except: + logging.warning("Cannot create a texture for %s. No such file.", filepath) + img = bpy.data.images.new(os.path.basename(filepath), 1, 1) + img.source = "FILE" + img.filepath = filepath + use_alpha = img.depth == 32 and img.file_format != "BMP" + if hasattr(img, "use_alpha"): + img.use_alpha = use_alpha + elif not use_alpha: + img.alpha_mode = "NONE" + return img + + def update_toon_texture(self): + if self._nodes_are_readonly: + return + 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)) + self.create_toon_texture(bpy.path.resolve_ncase(path=toon_path)) + elif mmd_mat.toon_texture != "": + self.create_toon_texture(mmd_mat.toon_texture) + else: + self.remove_toon_texture() + + def _mix_diffuse_and_ambient(self, mmd_mat): + r, g, b = mmd_mat.diffuse_color + ar, ag, ab = mmd_mat.ambient_color + return [min(1.0, 0.5 * r + ar), min(1.0, 0.5 * g + ag), min(1.0, 0.5 * b + ab)] + + def update_drop_shadow(self): + pass + + def update_enabled_toon_edge(self): + if self._nodes_are_readonly: + return + self.update_edge_color() + + def update_edge_color(self): + if self._nodes_are_readonly: + return + mat = self.__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) + if mat_edge: + mat_edge.mmd_material.edge_color = line_color + + if mat.name.startswith("mmd_edge.") and mat.node_tree: + mmd_mat.ambient_color, mmd_mat.alpha = color, alpha + node_shader = mat.node_tree.nodes.get("mmd_edge_preview", None) + if node_shader and "Color" in node_shader.inputs: + 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 + + def update_edge_weight(self): + pass + + def get_texture(self): + return self.__get_texture_node("mmd_base_tex", use_dummy=True) + + def create_texture(self, filepath): + texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1)) + return _DummyTextureSlot(texture.image) + + def remove_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_base_tex") + + def get_sphere_texture(self): + return self.__get_texture_node("mmd_sphere_tex", use_dummy=True) + + def use_sphere_texture(self, use_sphere, obj=None): + if self._nodes_are_readonly: + return + if use_sphere: + self.update_sphere_texture_type(obj) + else: + self.__update_shader_input("Sphere Tex Fac", 0) + + def create_sphere_texture(self, filepath, obj=None): + texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2)) + self.update_sphere_texture_type(obj) + return _DummyTextureSlot(texture.image) + + def update_sphere_texture_type(self, obj=None): + if self._nodes_are_readonly: + return + sphere_texture_type = int(self.material.mmd_material.sphere_texture_type) + is_sph_add = sphere_texture_type == 2 + + if sphere_texture_type not in (1, 2, 3): + self.__update_shader_input("Sphere Tex Fac", 0) + else: + self.__update_shader_input("Sphere Tex Fac", 1) + self.__update_shader_input("Sphere Mul/Add", is_sph_add) + self.__update_shader_input("Sphere Tex", (0, 0, 0, 1) if is_sph_add else (1, 1, 1, 1)) + + texture = self.__get_texture_node("mmd_sphere_tex") + if texture and (not texture.inputs["Vector"].is_linked or texture.inputs["Vector"].links[0].from_node.name == "mmd_tex_uv"): + if hasattr(texture, "color_space"): + texture.color_space = "NONE" if is_sph_add else "COLOR" + elif hasattr(texture.image, "colorspace_settings"): + texture.image.colorspace_settings.name = "Linear Rec.709" if is_sph_add else "sRGB" + + mat = self.material + nodes, links = mat.node_tree.nodes, mat.node_tree.links + if sphere_texture_type == 3: + if obj and obj.type == "MESH" and mat in tuple(obj.data.materials): + uv_layers = (l for l in obj.data.uv_layers if not l.name.startswith("_")) + 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) + 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"]) + + def remove_sphere_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_sphere_tex") + + def get_toon_texture(self): + return self.__get_texture_node("mmd_toon_tex", use_dummy=True) + + def use_toon_texture(self, use_toon): + if self._nodes_are_readonly: + return + self.__update_shader_input("Toon Tex Fac", use_toon) + + def create_toon_texture(self, filepath): + texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5)) + return _DummyTextureSlot(texture.image) + + def remove_toon_texture(self): + if self._nodes_are_readonly: + return + self.__remove_texture_node("mmd_toon_tex") + + def __get_texture_node(self, node_name, use_dummy=False): + mat = self.material + texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) + if isinstance(texture, bpy.types.ShaderNodeTexImage): + return _DummyTexture(texture.image) if use_dummy else texture + return None + + def __remove_texture_node(self, node_name): + 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): + texture = self.__get_texture_node(node_name) + if texture is None: + from mathutils import Vector + + self.__update_shader_nodes() + nodes = self.material.node_tree.nodes + texture = nodes.new("ShaderNodeTexImage") + # pylint: disable=assignment-from-no-return + texture.label = bpy.path.display_name(node_name) + texture.name = node_name + texture.location = nodes["mmd_shader"].location + Vector((pos[0] * 210, pos[1] * 220)) + texture.image = self._load_image(filepath) + self.__update_shader_nodes() + return texture + + def update_ambient_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) + self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,)) + + def update_diffuse_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) + self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,)) + + def update_alpha(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + if hasattr(mat, "blend_method"): + mat.blend_method = "HASHED" # 'BLEND' + # mat.show_transparent_back = False + elif hasattr(mat, "transparency_method"): + mat.use_transparency = True + mat.transparency_method = "Z_TRANSPARENCY" + mat.game_settings.alpha_blend = "ALPHA" + if hasattr(mat, "alpha"): + mat.alpha = mmd_mat.alpha + elif len(mat.diffuse_color) > 3: + mat.diffuse_color[3] = mmd_mat.alpha + self.__update_shader_input("Alpha", mmd_mat.alpha) + self.update_self_shadow_map() + + def update_specular_color(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + mat.specular_color = mmd_mat.specular_color + self.__update_shader_input("Specular Color", mmd_mat.specular_color[:] + (1,)) + + def update_shininess(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37) + if hasattr(mat, "metallic"): + mat.metallic = pow(1 - mat.roughness, 2.7) + if hasattr(mat, "specular_hardness"): + mat.specular_hardness = mmd_mat.shininess + self.__update_shader_input("Reflect", mmd_mat.shininess) + + def update_is_double_sided(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + if hasattr(mat, "game_settings"): + mat.game_settings.use_backface_culling = not mmd_mat.is_double_sided + 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) + + def update_self_shadow_map(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + 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" + + def update_self_shadow(self): + if self._nodes_are_readonly: + return + mat = self.material + mmd_mat = mat.mmd_material + self.__update_shader_input("Self Shadow", mmd_mat.enabled_self_shadow) + + @staticmethod + def convert_to_mmd_material(material, context=bpy.context): + m, mmd_material = material, material.mmd_material + + 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): + if node.type == "TEX_IMAGE": + return node + for node_input in node.inputs: + if not node_input.is_linked: + continue + child = search_tex_image_node(node_input.links[0].from_node) + if child is not None: + return child + return None + + if hasattr(context, "engine"): + active_render_engine = context.engine + else: + # use ALL anyway + active_render_engine = "ALL" + + preferred_output_node_target = { + "CYCLES": "CYCLES", + "BLENDER_EEVEE_NEXT": "EEVEE", + }.get(active_render_engine, "ALL") + + tex_node = None + for target in [preferred_output_node_target, "ALL"]: + output_node = m.node_tree.get_output_node(target) + if output_node is None: + continue + + if not output_node.inputs[0].is_linked: + continue + + tex_node = search_tex_image_node(output_node.inputs[0].links[0].from_node) + break + + 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: + tex_node.name = "mmd_base_tex" + else: + # Take the Base Color from BSDF if there's no texture + bsdf_node = next((n for n in m.node_tree.nodes if n.type.startswith('BSDF_')), None) + if bsdf_node: + base_color_input = bsdf_node.inputs.get('Base Color') or bsdf_node.inputs.get('Color') + if base_color_input: + 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] + + shadow_method = getattr(m, "shadow_method", None) + + if mmd_material.diffuse_color is None: + mmd_material.diffuse_color = m.diffuse_color[:3] + if hasattr(m, "alpha"): + mmd_material.alpha = m.alpha + elif len(m.diffuse_color) > 3: + mmd_material.alpha = m.diffuse_color[3] + + mmd_material.specular_color = m.specular_color + if hasattr(m, "specular_hardness"): + mmd_material.shininess = m.specular_hardness + else: + mmd_material.shininess = pow(1 / max(m.roughness, 0.099), 1 / 0.37) + + if hasattr(m, "game_settings"): + mmd_material.is_double_sided = not m.game_settings.use_backface_culling + elif hasattr(m, "use_backface_culling"): + mmd_material.is_double_sided = not m.use_backface_culling + + if shadow_method: + mmd_material.enabled_self_shadow_map = (shadow_method != "NONE") and mmd_material.alpha > 1e-3 + mmd_material.enabled_self_shadow = shadow_method != "NONE" + + # delete bsdf node if it's there + if m.use_nodes: + nodes_to_remove = [n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED' or n.type.startswith('BSDF_')] + for n in nodes_to_remove: + m.node_tree.nodes.remove(n) + + def __update_shader_input(self, name, val): + mat = self.material + if mat.name.startswith("mmd_"): # skip mmd_edge.* + return + self.__update_shader_nodes() + shader = mat.node_tree.nodes.get("mmd_shader", None) + if shader and name in shader.inputs: + interface_socket = shader.node_tree.interface.items_tree[name] + if hasattr(interface_socket, "min_value"): + val = min(max(val, interface_socket.min_value), interface_socket.max_value) + shader.inputs[name].default_value = val + + def __update_shader_nodes(self): + mat = self.material + if mat.node_tree is None: + 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 + + node_shader = nodes.get("mmd_shader", None) + if node_shader is None: + 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 + 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,) + node_shader.inputs.get("Reflect", _Dummy).default_value = mmd_mat.shininess + node_shader.inputs.get("Alpha", _Dummy).default_value = mmd_mat.alpha + node_shader.inputs.get("Double Sided", _Dummy).default_value = mmd_mat.is_double_sided + node_shader.inputs.get("Self Shadow", _Dummy).default_value = mmd_mat.enabled_self_shadow + self.update_sphere_texture_type() + + node_uv = nodes.get("mmd_tex_uv", None) + if node_uv is None: + 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)) + node_uv.node_tree = self.__get_shader_uv() + + if not (node_shader.outputs["Shader"].is_linked or node_shader.outputs["Color"].is_linked or node_shader.outputs["Alpha"].is_linked): + node_output = next((n for n in nodes if isinstance(n, bpy.types.ShaderNodeOutputMaterial) and n.is_active_output), None) + if node_output is None: + node_output: bpy.types.ShaderNodeOutputMaterial = nodes.new("ShaderNodeOutputMaterial") + node_output.is_active_output = True + node_output.location = node_shader.location + Vector((400, 0)) + links.new(node_shader.outputs["Shader"], node_output.inputs["Surface"]) + + for name_id in ("Base", "Toon", "Sphere"): + texture = self.__get_texture_node("mmd_%s_tex" % name_id.lower()) + if texture: + name_tex_in, name_alpha_in, name_uv_out = (name_id + x for x in (" Tex", " Alpha", " UV")) + if not node_shader.inputs.get(name_tex_in, _Dummy).is_linked: + links.new(texture.outputs["Color"], node_shader.inputs[name_tex_in]) + if not node_shader.inputs.get(name_alpha_in, _Dummy).is_linked: + links.new(texture.outputs["Alpha"], node_shader.inputs[name_alpha_in]) + if not texture.inputs["Vector"].is_linked: + links.new(node_uv.outputs[name_uv_out], texture.inputs["Vector"]) + + def __get_shader_uv(self): + group_name = "MMDTexUV" + shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + if len(shader.nodes): + return shader + + ng = _NodeGroupUtils(shader) + + ############################################################################ + _node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (6, 0)) + + tex_coord: bpy.types.ShaderNodeTexCoord = ng.new_node("ShaderNodeTexCoord", (0, 0)) + + tex_coord1: bpy.types.ShaderNodeUVMap = ng.new_node("ShaderNodeUVMap", (4, -2)) + tex_coord1.uv_map = "UV1" + + vec_trans: bpy.types.ShaderNodeVectorTransform = ng.new_node("ShaderNodeVectorTransform", (1, -1)) + vec_trans.vector_type = "NORMAL" + vec_trans.convert_from = "OBJECT" + vec_trans.convert_to = "CAMERA" + + node_vector: bpy.types.ShaderNodeMapping = ng.new_node("ShaderNodeMapping", (2, -1)) + node_vector.vector_type = "POINT" + node_vector.inputs["Location"].default_value = (0.5, 0.5, 0.0) + node_vector.inputs["Scale"].default_value = (0.5, 0.5, 1.0) + + links = ng.links + links.new(tex_coord.outputs["Normal"], vec_trans.inputs["Vector"]) + links.new(vec_trans.outputs["Vector"], node_vector.inputs["Vector"]) + + ng.new_output_socket("Base UV", tex_coord.outputs["UV"]) + ng.new_output_socket("Toon UV", node_vector.outputs["Vector"]) + ng.new_output_socket("Sphere UV", node_vector.outputs["Vector"]) + ng.new_output_socket("SubTex UV", tex_coord1.outputs["UV"]) + + return shader + + def __get_shader(self): + group_name = "MMDShaderDev" + shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + if len(shader.nodes): + return shader + + ng = _NodeGroupUtils(shader) + + ############################################################################ + node_input: bpy.types.NodeGroupInput = ng.new_node("NodeGroupInput", (-5, -1)) + _node_output: bpy.types.NodeGroupOutput = ng.new_node("NodeGroupOutput", (11, 1)) + + node_diffuse: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (-3, 4), fac=0.6) + node_diffuse.use_clamp = True + + node_tex: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-2, 3.5)) + node_toon: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (-1, 3)) + node_sph: bpy.types.ShaderNodeMath = ng.new_mix_node("MULTIPLY", (0, 2.5)) + node_spa: bpy.types.ShaderNodeMath = ng.new_mix_node("ADD", (0, 1.5)) + node_sphere: bpy.types.ShaderNodeMath = ng.new_mix_node("MIX", (1, 1)) + + node_geo: bpy.types.ShaderNodeNewGeometry = ng.new_node("ShaderNodeNewGeometry", (6, 3.5)) + node_invert: bpy.types.ShaderNodeMath = ng.new_math_node("LESS_THAN", (7, 3)) + node_cull: bpy.types.ShaderNodeMath = ng.new_math_node("MAXIMUM", (8, 2.5)) + node_alpha: bpy.types.ShaderNodeMath = ng.new_math_node("MINIMUM", (9, 2)) + node_alpha.use_clamp = True + node_alpha_tex: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (-1, -2)) + node_alpha_toon: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (0, -2.5)) + node_alpha_sph: bpy.types.ShaderNodeMath = ng.new_math_node("MULTIPLY", (1, -3)) + + node_reflect: bpy.types.ShaderNodeMath = ng.new_math_node("DIVIDE", (7, -1.5), value1=1) + node_reflect.use_clamp = True + + shader_diffuse: bpy.types.ShaderNodeBsdfDiffuse = ng.new_node("ShaderNodeBsdfDiffuse", (8, 0)) + shader_glossy: bpy.types.ShaderNodeBsdfAnisotropic = ng.new_node("ShaderNodeBsdfAnisotropic", (8, -1)) + shader_base_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (9, 0)) + shader_base_mix.inputs["Fac"].default_value = 0.02 + shader_trans: bpy.types.ShaderNodeBsdfTransparent = ng.new_node("ShaderNodeBsdfTransparent", (9, 1)) + shader_alpha_mix: bpy.types.ShaderNodeMixShader = ng.new_node("ShaderNodeMixShader", (10, 1)) + + links = ng.links + links.new(node_reflect.outputs["Value"], shader_glossy.inputs["Roughness"]) + links.new(shader_diffuse.outputs["BSDF"], shader_base_mix.inputs[1]) + links.new(shader_glossy.outputs["BSDF"], shader_base_mix.inputs[2]) + + links.new(node_diffuse.outputs["Color"], node_tex.inputs["Color1"]) + links.new(node_tex.outputs["Color"], node_toon.inputs["Color1"]) + links.new(node_toon.outputs["Color"], node_sph.inputs["Color1"]) + links.new(node_toon.outputs["Color"], node_spa.inputs["Color1"]) + links.new(node_sph.outputs["Color"], node_sphere.inputs["Color1"]) + links.new(node_spa.outputs["Color"], node_sphere.inputs["Color2"]) + links.new(node_sphere.outputs["Color"], shader_diffuse.inputs["Color"]) + + links.new(node_geo.outputs["Backfacing"], node_invert.inputs[0]) + links.new(node_invert.outputs["Value"], node_cull.inputs[0]) + links.new(node_cull.outputs["Value"], node_alpha.inputs[0]) + links.new(node_alpha_tex.outputs["Value"], node_alpha_toon.inputs[0]) + links.new(node_alpha_toon.outputs["Value"], node_alpha_sph.inputs[0]) + links.new(node_alpha_sph.outputs["Value"], node_alpha.inputs[1]) + + links.new(node_alpha.outputs["Value"], shader_alpha_mix.inputs["Fac"]) + links.new(shader_trans.outputs["BSDF"], shader_alpha_mix.inputs[1]) + links.new(shader_base_mix.outputs["Shader"], shader_alpha_mix.inputs[2]) + + ############################################################################ + ng.new_input_socket("Ambient Color", node_diffuse.inputs["Color1"], (0.4, 0.4, 0.4, 1)) + ng.new_input_socket("Diffuse Color", node_diffuse.inputs["Color2"], (0.8, 0.8, 0.8, 1)) + # ↓ specular should be disabled by default + ng.new_input_socket("Specular Color", shader_glossy.inputs["Color"], (0.0, 0.0, 0.0, 1)) + ng.new_input_socket("Reflect", node_reflect.inputs[1], 50, min_max=(1, 512)) + ng.new_input_socket("Base Tex Fac", node_tex.inputs["Fac"], 1) + ng.new_input_socket("Base Tex", node_tex.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Toon Tex Fac", node_toon.inputs["Fac"], 1) + ng.new_input_socket("Toon Tex", node_toon.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Sphere Tex Fac", node_sph.inputs["Fac"], 1) + ng.new_input_socket("Sphere Tex", node_sph.inputs["Color2"], (1, 1, 1, 1)) + ng.new_input_socket("Sphere Mul/Add", node_sphere.inputs["Fac"], 0) + ng.new_input_socket("Double Sided", node_cull.inputs[1], 0, min_max=(0, 1)) + ng.new_input_socket("Alpha", node_alpha_tex.inputs[0], 1, min_max=(0, 1)) + ng.new_input_socket("Base Alpha", node_alpha_tex.inputs[1], 1, min_max=(0, 1)) + ng.new_input_socket("Toon Alpha", node_alpha_toon.inputs[1], 1, min_max=(0, 1)) + ng.new_input_socket("Sphere Alpha", node_alpha_sph.inputs[1], 1, min_max=(0, 1)) + + links.new(node_input.outputs["Sphere Tex Fac"], node_spa.inputs["Fac"]) + links.new(node_input.outputs["Sphere Tex"], node_spa.inputs["Color2"]) + + ng.new_output_socket("Shader", shader_alpha_mix.outputs["Shader"]) + ng.new_output_socket("Color", node_sphere.outputs["Color"]) + ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) + + return shader + + +class MigrationFnMaterial: + @staticmethod + def update_mmd_shader(): + mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev") + if mmd_shader_node_tree is None: + return + + ng = _NodeGroupUtils(mmd_shader_node_tree) + if "Color" in ng.node_output.inputs: + return + + 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 + shader_alpha_mix: bpy.types.ShaderNodeMixShader = node_output.inputs["Shader"].links[0].from_node + node_alpha: bpy.types.ShaderNodeMath = shader_alpha_mix.inputs["Fac"].links[0].from_node + + ng.new_output_socket("Color", node_sphere.outputs["Color"]) + ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) diff --git a/core/mmd/core/model.py b/core/mmd/core/model.py new file mode 100644 index 0000000..103d52f --- /dev/null +++ b/core/mmd/core/model.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +import logging +import time +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Optional, Set, TypeGuard, Union, cast + +import bpy +import idprop +import rna_prop_ui +from mathutils import Vector + +from .. import AVATAR_TOOLKIT_VERSION, bpyutils +from ..bpyutils import FnContext, Props +from . import rigid_body +from .morph import FnMorph +from .rigid_body import MODE_DYNAMIC, MODE_DYNAMIC_BONE, MODE_STATIC + +if TYPE_CHECKING: + from ..properties.morph import MaterialMorphData + from ..properties.rigid_body import MMDRigidBody + + +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): + FnModel.__copy_property(destination_root_object.mmd_root, source_root_object.mmd_root, overwrite=overwrite, replace_name2values=replace_name2values or {}) + + @staticmethod + def find_root_object(obj: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + """Find the root object of the model. + Args: + obj (bpy.types.Object): The object to start searching from. + Returns: + 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": + obj = obj.parent + return obj + + @staticmethod + def find_armature_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + """Find the armature object of the model. + Args: + root_object (bpy.types.Object): The root object of the model. + Returns: + Optional[bpy.types.Object]: The armature object of the model. If the model does not have an armature, None is returned. + """ + for o in root_object.children: + if o.type == "ARMATURE": + return o + return None + + @staticmethod + def find_rigid_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "RIGID_GRP_OBJ": + return o + return None + + @staticmethod + def __new_group_object(context: bpy.types.Context, name: str, mmd_type: str, parent: bpy.types.Object) -> bpy.types.Object: + group_object = FnContext.new_and_link_object(context, name=name, object_data=None) + group_object.mmd_type = mmd_type + group_object.parent = parent + group_object.hide_set(True) + group_object.hide_select = True + group_object.lock_rotation = group_object.lock_location = group_object.lock_scale = [True, True, True] + return group_object + + @staticmethod + def ensure_rigid_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + rigid_group_object = FnModel.find_rigid_group_object(root_object) + if rigid_group_object is not None: + return rigid_group_object + return FnModel.__new_group_object(context, name="rigidbodies", mmd_type="RIGID_GRP_OBJ", parent=root_object) + + @staticmethod + def find_joint_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "JOINT_GRP_OBJ": + return o + return None + + @staticmethod + def ensure_joint_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + joint_group_object = FnModel.find_joint_group_object(root_object) + if joint_group_object is not None: + return joint_group_object + return FnModel.__new_group_object(context, name="joints", mmd_type="JOINT_GRP_OBJ", parent=root_object) + + @staticmethod + def find_temporary_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + for o in root_object.children: + if o.type == "EMPTY" and o.mmd_type == "TEMPORARY_GRP_OBJ": + return o + return None + + @staticmethod + def ensure_temporary_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + temporary_group_object = FnModel.find_temporary_group_object(root_object) + if temporary_group_object is not None: + return temporary_group_object + return FnModel.__new_group_object(context, name="temporary", mmd_type="TEMPORARY_GRP_OBJ", parent=root_object) + + @staticmethod + def find_bone_order_mesh_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return None + + for o in armature_object.children: + if o.type == "MESH" and "mmd_bone_order_override" in o.modifiers: + return o + return None + + @staticmethod + def find_mesh_object_by_name(root_object: bpy.types.Object, name: str) -> Optional[bpy.types.Object]: + if not name: + return None + + for o in FnModel.iterate_mesh_objects(root_object): + if o.name == name or (hasattr(o.data, 'name') and o.data.name == name): + return o + return None + + @staticmethod + def iterate_child_objects(obj: bpy.types.Object) -> Iterator[bpy.types.Object]: + for child in obj.children: + yield child + yield from FnModel.iterate_child_objects(child) + + @staticmethod + def iterate_filtered_child_objects(condition_function: Callable[[bpy.types.Object], bool], obj: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if obj is None: + return iter(()) + return FnModel.__iterate_filtered_child_objects_internal(condition_function, obj) + + @staticmethod + def __iterate_filtered_child_objects_internal(condition_function: Callable[[bpy.types.Object], bool], obj: bpy.types.Object) -> Iterator[bpy.types.Object]: + for child in obj.children: + if condition_function(child): + yield child + yield from FnModel.__iterate_filtered_child_objects_internal(condition_function, child) + + @staticmethod + def __iterate_child_mesh_objects(obj: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + return FnModel.iterate_filtered_child_objects(FnModel.is_mesh_object, obj) + + @staticmethod + def iterate_mesh_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + return FnModel.__iterate_child_mesh_objects(FnModel.find_armature_object(root_object)) + + @staticmethod + def iterate_rigid_body_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + if root_object.mmd_root.is_built: + return itertools.chain( + FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_armature_object(root_object)), + FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_rigid_group_object(root_object)), + ) + return FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_rigid_group_object(root_object)) + + @staticmethod + def iterate_joint_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + return FnModel.iterate_filtered_child_objects(FnModel.is_joint_object, FnModel.find_joint_group_object(root_object)) + + @staticmethod + def iterate_temporary_objects(root_object: bpy.types.Object, rigid_track_only: bool = False) -> Iterator[bpy.types.Object]: + rigid_body_objects = FnModel.iterate_filtered_child_objects(FnModel.is_temporary_object, FnModel.find_rigid_group_object(root_object)) + + if rigid_track_only: + return rigid_body_objects + + temporary_group_object = FnModel.find_temporary_group_object(root_object) + if temporary_group_object is None: + return rigid_body_objects + return itertools.chain(rigid_body_objects, FnModel.__iterate_filtered_child_objects_internal(FnModel.is_temporary_object, temporary_group_object)) + + @staticmethod + def iterate_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: + return (material for mesh_object in FnModel.iterate_mesh_objects(root_object) for material in cast(bpy.types.Mesh, mesh_object.data).materials if material is not None) + + @staticmethod + def iterate_unique_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: + materials: Dict[bpy.types.Material, None] = {} # use dict because set does not guarantee the order + materials.update((material, None) for material in FnModel.iterate_materials(root_object)) + return iter(materials.keys()) + + @staticmethod + def is_root_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "ROOT" + + @staticmethod + def is_rigid_body_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "RIGID_BODY" + + @staticmethod + def is_joint_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type == "JOINT" + + @staticmethod + def is_temporary_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + return obj is not None and obj.mmd_type in {"TRACK_TARGET", "NON_COLLISION_CONSTRAINT", "SPRING_CONSTRAINT", "SPRING_GOAL"} + + @staticmethod + def is_mesh_object(obj: Optional[bpy.types.Object]) -> TypeGuard[bpy.types.Object]: + 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]): + parent_armature_object = FnModel.find_armature_object(parent_root_object) + with bpy.context.temp_override( + active_object=parent_armature_object, + selected_editable_objects=[parent_armature_object], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + def _change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs, pose_bones): + """This function will also update the references of bone morphs and rotate+/move+.""" + bone_id = bone.mmd_bone.bone_id + + # Change Bone ID + bone.mmd_bone.bone_id = new_bone_id + + # Update Relative Bone Morph # Update the reference of bone morph # 更新骨骼表情 + for bone_morph in bone_morphs: + for data in bone_morph.data: + if data.bone_id != bone_id: + continue + data.bone_id = new_bone_id + + # Update Relative Additional Transform # Update the reference of rotate+/move+ # 更新付与親 + for pose_bone in pose_bones: + if pose_bone.is_mmd_shadow_bone: + continue + mmd_bone = pose_bone.mmd_bone + if mmd_bone.additional_transform_bone_id != bone_id: + continue + mmd_bone.additional_transform_bone_id = new_bone_id + + max_bone_id = max( + ( + b.mmd_bone.bone_id + for o in itertools.chain( + child_root_objects, + [parent_root_object], + ) + for b in FnModel.find_armature_object(o).pose.bones + if not b.is_mmd_shadow_bone + ), + default=-1, + ) + + child_root_object: bpy.types.Object + for child_root_object in child_root_objects: + 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 + + for pose_bone in child_pose_bones: + if pose_bone.is_mmd_shadow_bone: + continue + if pose_bone.mmd_bone.bone_id != -1: + max_bone_id += 1 + _change_bone_id(pose_bone, max_bone_id, child_bone_morphs, child_pose_bones) + + child_armature_matrix = child_armature_object.matrix_parent_inverse.copy() + + with bpy.context.temp_override( + active_object=child_armature_object, + selected_editable_objects=[child_armature_object], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + # Disconnect mesh dependencies because transform_apply fails when mesh data are multiple used. + related_meshes: Dict[MaterialMorphData, bpy.types.Mesh] = {} + for material_morph in child_root_object.mmd_root.material_morphs: + for material_morph_data in material_morph.data: + if material_morph_data.related_mesh_data is not None: + related_meshes[material_morph_data] = material_morph_data.related_mesh_data + material_morph_data.related_mesh_data = None + try: + # replace mesh armature modifier.object + mesh: bpy.types.Object + for mesh in FnModel.__iterate_child_mesh_objects(child_armature_object): + with bpy.context.temp_override( + active_object=mesh, + selected_editable_objects=[mesh], + ): + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + finally: + # Restore mesh dependencies + for material_morph in child_root_object.mmd_root.material_morphs: + for material_morph_data in material_morph.data: + material_morph_data.related_mesh_data = related_meshes.get(material_morph_data, None) + + # join armatures + with bpy.context.temp_override( + active_object=parent_armature_object, + selected_editable_objects=[parent_armature_object, child_armature_object], + ): + bpy.ops.object.join() + + for mesh in FnModel.__iterate_child_mesh_objects(parent_armature_object): + armature_modifier: bpy.types.ArmatureModifier = mesh.modifiers["mmd_bone_order_override"] if "mmd_bone_order_override" in mesh.modifiers else mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") + if armature_modifier.object is None: + armature_modifier.object = parent_armature_object + mesh.matrix_parent_inverse = child_armature_matrix + + child_rigid_group_object = FnModel.find_rigid_group_object(child_root_object) + if child_rigid_group_object is not None: + parent_rigid_group_object = FnModel.find_rigid_group_object(parent_root_object) + + with bpy.context.temp_override( + object=parent_rigid_group_object, + selected_editable_objects=[parent_rigid_group_object, *FnModel.iterate_rigid_body_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + bpy.data.objects.remove(child_rigid_group_object) + + child_joint_group_object = FnModel.find_joint_group_object(child_root_object) + if child_joint_group_object is not None: + parent_joint_group_object = FnModel.find_joint_group_object(parent_root_object) + with bpy.context.temp_override( + object=parent_joint_group_object, + selected_editable_objects=[parent_joint_group_object, *FnModel.iterate_joint_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + bpy.data.objects.remove(child_joint_group_object) + + child_temporary_group_object = FnModel.find_temporary_group_object(child_root_object) + if child_temporary_group_object is not None: + parent_temporary_group_object = FnModel.find_temporary_group_object(parent_root_object) + with bpy.context.temp_override( + object=parent_temporary_group_object, + selected_editable_objects=[parent_temporary_group_object, *FnModel.iterate_temporary_objects(child_root_object)], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + for obj in list(FnModel.iterate_child_objects(child_temporary_group_object)): + bpy.data.objects.remove(obj) + bpy.data.objects.remove(child_temporary_group_object) + + FnModel.copy_mmd_root(parent_root_object, child_root_object, overwrite=False) + + # Remove unused objects from child models + if len(child_root_object.children) == 0: + bpy.data.objects.remove(child_root_object) + + @staticmethod + def _add_armature_modifier(mesh_object: bpy.types.Object, armature_object: bpy.types.Object) -> bpy.types.ArmatureModifier: + for m in mesh_object.modifiers: + if m.type != "ARMATURE": + continue + # already has armature modifier. + return cast(bpy.types.ArmatureModifier, m) + + modifier = cast(bpy.types.ArmatureModifier, mesh_object.modifiers.new(name="Armature", type="ARMATURE")) + modifier.object = armature_object + modifier.use_vertex_groups = True + modifier.name = "mmd_bone_order_override" + + return modifier + + @staticmethod + def attach_mesh_objects(parent_root_object: bpy.types.Object, mesh_objects: Iterable[bpy.types.Object], add_armature_modifier: bool): + armature_object = FnModel.find_armature_object(parent_root_object) + if armature_object is None: + raise ValueError(f"Armature object not found in {parent_root_object}") + + def __get_root_object(obj: bpy.types.Object) -> bpy.types.Object: + if obj.parent is None: + return obj + return __get_root_object(obj.parent) + + for mesh_object in mesh_objects: + if not FnModel.is_mesh_object(mesh_object): + continue + + if FnModel.find_root_object(mesh_object) is not None: + continue + + mesh_root_object = __get_root_object(mesh_object) + original_matrix_world = mesh_root_object.matrix_world + mesh_root_object.parent_type = "OBJECT" + mesh_root_object.parent = armature_object + mesh_root_object.matrix_world = original_matrix_world + + if add_armature_modifier: + FnModel._add_armature_modifier(mesh_object, armature_object) + + @staticmethod + def add_missing_vertex_groups_from_bones(root_object: bpy.types.Object, mesh_object: bpy.types.Object, search_in_all_meshes: bool): + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + raise ValueError(f"Armature object not found in {root_object}") + + vertex_group_names: Set[str] = set() + + search_meshes = FnModel.iterate_mesh_objects(root_object) if search_in_all_meshes else [mesh_object] + + for search_mesh in search_meshes: + vertex_group_names.update(search_mesh.vertex_groups.keys()) + + pose_bone: bpy.types.PoseBone + for pose_bone in armature_object.pose.bones: + pose_bone_name = pose_bone.name + + if pose_bone_name in vertex_group_names: + continue + + if pose_bone_name.startswith("_"): + continue + + mesh_object.vertex_groups.new(name=pose_bone_name) + + @staticmethod + def change_mmd_ik_loop_factor(root_object: bpy.types.Object, new_ik_loop_factor: int): + mmd_root = root_object.mmd_root + old_ik_loop_factor = mmd_root.ik_loop_factor + + if new_ik_loop_factor == old_ik_loop_factor: + return + + armature_object = FnModel.find_armature_object(root_object) + 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) + constraint.iterations = iterations + + mmd_root.ik_loop_factor = new_ik_loop_factor + + return + + @staticmethod + def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): + destination_rna_properties = destination.bl_rna.properties + for name in source.keys(): + is_attr = hasattr(source, name) + value = getattr(source, name) if is_attr else source[name] + if isinstance(value, bpy.types.PropertyGroup): + FnModel.__copy_property_group(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + elif isinstance(value, bpy.types.bpy_prop_collection): + FnModel.__copy_collection_property(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + elif isinstance(value, idprop.types.IDPropertyArray): + pass + # _copy_collection_property(getattr(destination, name) if is_attr else destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + else: + value2values = replace_name2values.get(name) + if value2values is not None: + replace_value = value2values.get(value) + if replace_value is not None: + value = replace_value + + if overwrite or destination_rna_properties[name].default == getattr(destination, name) if is_attr else destination[name]: + if is_attr: + setattr(destination, name, value) + else: + 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]]): + if overwrite: + destination.clear() + + len_source = len(source) + if len_source == 0: + return + + source_names: Set[str] = set(source.keys()) + if len(source_names) == len_source and source[0].name != "": + # names work + destination_names: Set[str] = set(destination.keys()) + + missing_names = source_names - destination_names + + destination_index = 0 + for name, value in source.items(): + if name in missing_names: + new_element = destination.add() + new_element["name"] = name + + FnModel.__copy_property(destination[name], value, overwrite=overwrite, replace_name2values=replace_name2values) + destination.move(destination.find(name), destination_index) + destination_index += 1 + else: + # names not work + while len_source > len(destination): + destination.add() + + for index, name in enumerate(source.keys()): + 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]]): + 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}") + + @staticmethod + def initalize_display_item_frames(root_object: bpy.types.Object, reset: bool = True): + frames = root_object.mmd_root.display_item_frames + if reset and len(frames) > 0: + root_object.mmd_root.active_display_item_frame = 0 + frames.clear() + + frame_names = {"Root": "Root", "表情": "Facial"} + + for frame_name, frame_name_e in frame_names.items(): + frame = frames.get(frame_name, None) or frames.add() + frame.name = frame_name + frame.name_e = frame_name_e + frame.is_special = True + + arm = FnModel.find_armature_object(root_object) + if arm is not None and len(arm.data.bones) > 0 and len(frames[0].data) < 1: + item = frames[0].data.add() + item.type = "BONE" + item.name = arm.data.bones[0].name + + if not reset: + frames.move(frames.find("Root"), 0) + frames.move(frames.find("表情"), 1) + + @staticmethod + def get_empty_display_size(root_object: bpy.types.Object) -> float: + return getattr(root_object, Props.empty_display_size) + + +class MigrationFnModel: + """Migration Functions for old MMD models broken by bugs or issues""" + + @classmethod + def update_mmd_ik_loop_factor(cls): + for armature_object in bpy.data.objects: + if armature_object.type != "ARMATURE": + continue + + if "mmd_ik_loop_factor" not in armature_object: + return + + FnModel.find_root_object(armature_object).mmd_root.ik_loop_factor = max(armature_object["mmd_ik_loop_factor"], 1) + del armature_object["mmd_ik_loop_factor"] + + @staticmethod + def update_avatar_toolkit_version(): + for root_object in bpy.data.objects: + if root_object.type != "EMPTY": + continue + + if not FnModel.is_root_object(root_object): + continue + + if "avatar_toolkit_version" in root_object: + continue + + root_object["avatar_toolkit_version"] = "0.2.1" + + +class Model: + def __init__(self, root_obj): + if root_obj is None: + raise ValueError("must be MMD ROOT type object") + if root_obj.mmd_type != "ROOT": + raise ValueError("must be MMD ROOT type object") + self.__root: bpy.types.Object = getattr(root_obj, "original", root_obj) + self.__arm: Optional[bpy.types.Object] = None + self.__rigid_grp: Optional[bpy.types.Object] = None + self.__joint_grp: Optional[bpy.types.Object] = None + self.__temporary_grp: Optional[bpy.types.Object] = None + + @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): + if obj_name is None: + obj_name = name + + context = FnContext.ensure_context() + + root: bpy.types.Object = bpy.data.objects.new(name=obj_name, object_data=None) + root.mmd_type = "ROOT" + root.mmd_root.name = name + root.mmd_root.name_e = name_e + root["avatar_toolkit_version"] = AVATAR_TOOLKIT_VERSION + setattr(root, Props.empty_display_size, scale / 0.2) + FnContext.link_object(context, root) + + if armature_object: + m = armature_object.matrix_world + armature_object.parent_type = "OBJECT" + armature_object.parent = root + # armature_object.matrix_world = m + root.matrix_world = m + armature_object.matrix_local.identity() + else: + 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) + armature_object.lock_rotation = armature_object.lock_location = armature_object.lock_scale = [True, True, True] + setattr(armature_object, Props.show_in_front, True) + setattr(armature_object, Props.display_type, "WIRE") + + from .bone import FnBone + + FnBone.setup_special_bone_collections(armature_object) + + if add_root_bone: + bone_name = "全ての親" + bone_name_english = "Root" + + # Create the root bone + with bpyutils.edit_object(armature_object) as data: + bone = data.edit_bones.new(name=bone_name) + bone.head = (0.0, 0.0, 0.0) + bone.tail = (0.0, 0.0, getattr(root, Props.empty_display_size)) + + # Set MMD bone properties + pose_bone = armature_object.pose.bones[bone_name] + pose_bone.mmd_bone.name_j = bone_name + pose_bone.mmd_bone.name_e = bone_name_english + + # Create a bone collection named "Root" + bone_collection_name = bone_name_english + bone_collection = armature_object.data.collections.new(name=bone_collection_name) + + # Assign the new bone to the bone collection + data_bone = armature_object.data.bones[bone_name] + bone_collection.assign(data_bone) + + FnContext.set_active_and_select_single_object(context, root) + 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): + FnModel.initalize_display_item_frames(self.__root, reset=reset) + + @property + def morph_slider(self): + return FnMorph.get_morph_slider(self) + + def loadMorphs(self): + FnMorph.load_morphs(self) + + def create_ik_constraint(self, bone, ik_target): + """create IK constraint + + Args: + bone: A pose bone to add a IK constraint + id_target: A pose bone for IK target + + Returns: + The bpy.types.KinematicConstraint object created. It is set target + and subtarget options. + + """ + ik_target_name = ik_target.name + ik_const = bone.constraints.new("IK") + ik_const.target = self.__arm + ik_const.subtarget = ik_target_name + return ik_const + + def allObjects(self, obj: Optional[bpy.types.Object] = None) -> Iterator[bpy.types.Object]: + if obj is None: + obj: bpy.types.Object = self.__root + yield obj + yield from FnModel.iterate_child_objects(obj) + + def rootObject(self) -> bpy.types.Object: + return self.__root + + def armature(self) -> bpy.types.Object: + if self.__arm is None: + self.__arm = FnModel.find_armature_object(self.__root) + assert self.__arm is not None + return self.__arm + + def hasRigidGroupObject(self) -> bool: + return FnModel.find_rigid_group_object(self.__root) is not None + + def rigidGroupObject(self) -> bpy.types.Object: + if self.__rigid_grp is None: + self.__rigid_grp = FnModel.find_rigid_group_object(self.__root) + if self.__rigid_grp is None: + rigids = bpy.data.objects.new(name="rigidbodies", object_data=None) + FnContext.link_object(FnContext.ensure_context(), rigids) + rigids.mmd_type = "RIGID_GRP_OBJ" + rigids.parent = self.__root + rigids.hide_set(True) + rigids.hide_select = True + rigids.lock_rotation = rigids.lock_location = rigids.lock_scale = [True, True, True] + self.__rigid_grp = rigids + return self.__rigid_grp + + def hasJointGroupObject(self) -> bool: + return FnModel.find_joint_group_object(self.__root) is not None + + def jointGroupObject(self) -> bpy.types.Object: + if self.__joint_grp is None: + self.__joint_grp = FnModel.find_joint_group_object(self.__root) + if self.__joint_grp is None: + joints = bpy.data.objects.new(name="joints", object_data=None) + FnContext.link_object(FnContext.ensure_context(), joints) + joints.mmd_type = "JOINT_GRP_OBJ" + joints.parent = self.__root + joints.hide_set(True) + joints.hide_select = True + joints.lock_rotation = joints.lock_location = joints.lock_scale = [True, True, True] + self.__joint_grp = joints + return self.__joint_grp + + def hasTemporaryGroupObject(self) -> bool: + return FnModel.find_temporary_group_object(self.__root) is not None + + def temporaryGroupObject(self) -> bpy.types.Object: + if self.__temporary_grp is None: + self.__temporary_grp = FnModel.find_temporary_group_object(self.__root) + if self.__temporary_grp is None: + temporarys = bpy.data.objects.new(name="temporary", object_data=None) + FnContext.link_object(FnContext.ensure_context(), temporarys) + temporarys.mmd_type = "TEMPORARY_GRP_OBJ" + temporarys.parent = self.__root + temporarys.hide_set(True) + temporarys.hide_select = True + temporarys.lock_rotation = temporarys.lock_location = temporarys.lock_scale = [True, True, True] + self.__temporary_grp = temporarys + return self.__temporary_grp + + 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): + FnModel.attach_mesh_objects(self.rootObject(), meshes, add_armature_modifier) + + def firstMesh(self) -> Optional[bpy.types.Object]: + for i in self.meshes(): + return i + return None + + def findMesh(self, mesh_name) -> Optional[bpy.types.Object]: + """ + Helper method to find a mesh by name + """ + if mesh_name == "": + return None + for mesh in self.meshes(): + if mesh.name == mesh_name or mesh.data.name == mesh_name: + return mesh + return None + + def findMeshByIndex(self, index: int) -> Optional[bpy.types.Object]: + """ + Helper method to find the mesh by index + """ + if index < 0: + return None + for i, mesh in enumerate(self.meshes()): + if i == index: + return mesh + return None + + def getMeshIndex(self, mesh_name: str) -> int: + """ + Helper method to get the index of a mesh. Returns -1 if not found + """ + if mesh_name == "": + return -1 + for i, mesh in enumerate(self.meshes()): + if mesh.name == mesh_name or mesh.data.name == mesh_name: + return i + return -1 + + def rigidBodies(self) -> Iterator[bpy.types.Object]: + return FnModel.iterate_rigid_body_objects(self.__root) + + 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]: + 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 + 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): + if old_bone_name == new_bone_name: + return + armature = self.armature() + bone = armature.pose.bones[old_bone_name] + bone.name = new_bone_name + new_bone_name = bone.name + + mmd_root = self.rootObject().mmd_root + for frame in mmd_root.display_item_frames: + for item in frame.data: + if item.type == "BONE" and item.name == old_bone_name: + item.name = new_bone_name + for mesh in self.meshes(): + 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): + rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) + if self.__root.mmd_root.is_built: + self.clean() + self.__root.mmd_root.is_built = True + logging.info("****************************************") + logging.info(" Build rig") + logging.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) + rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) + + def clean(self): + rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) + logging.info("****************************************") + logging.info(" Clean rig") + logging.info("****************************************") + start_time = time.time() + + pose_bones = [] + arm = self.armature() + if arm is not None: + pose_bones = arm.pose.bones + for i in pose_bones: + if "mmd_tools_rigid_track" in i.constraints: + const = i.constraints["mmd_tools_rigid_track"] + i.constraints.remove(const) + + 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) + i.mmd_rigid.bone = i.mmd_rigid.bone + relation = i.constraints["mmd_tools_rigid_parent"] + relation.mute = True + if rigid_type == rigid_body.MODE_STATIC: + i.parent_type = "OBJECT" + i.parent = self.rigidGroupObject() + elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name != "": + for c in arm.pose.bones[bone_name].constraints: + if c.type == "IK": + c.mute = False + self.__restoreTransforms(i) + + for i in self.joints(): + self.__restoreTransforms(i) + + self.__removeTemporaryObjects() + self.connectPhysicsBones() + + arm = self.armature() + if arm is not None: # update armature + arm.update_tag() + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + 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) + mmd_root.is_built = False + rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) + + def __removeTemporaryObjects(self): + with bpy.context.temp_override(selected_objects=tuple(self.temporaryObjects()), active_object=self.rootObject()): + bpy.ops.object.delete() + + def __restoreTransforms(self, obj): + 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] + + def __backupTransforms(self, obj): + 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) + + def __preBuild(self): + self.__fake_parent_map = {} + self.__rigid_body_matrix_map = {} + self.__empty_parent_map = {} + + no_parents = [] + for i in self.rigidBodies(): + self.__backupTransforms(i) + # mute relation + relation = i.constraints["mmd_tools_rigid_parent"] + relation.mute = True + # mute IK + if int(i.mmd_rigid.type) in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name != "": + for c in arm.pose.bones[bone_name].constraints: + if c.type == "IK": + c.mute = True + c.influence = c.influence # trigger update + else: + no_parents.append(i) + # update changes of armature constraints + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + parented = [] + for i in self.joints(): + self.__backupTransforms(i) + rbc = i.rigid_body_constraint + if rbc is None: + continue + obj1, obj2 = rbc.object1, rbc.object2 + if obj2 in no_parents: + if obj1 not in no_parents and obj2 not in parented: + self.__fake_parent_map.setdefault(obj1, []).append(obj2) + parented.append(obj2) + elif obj1 in no_parents: + if obj1 not in parented: + self.__fake_parent_map.setdefault(obj2, []).append(obj1) + parented.append(obj1) + + # assert(len(no_parents) == len(parented)) + + def __postBuild(self): + self.__fake_parent_map = None + self.__rigid_body_matrix_map = None + + # update changes + bpy.context.scene.frame_set(bpy.context.scene.frame_current) + + # parenting empty to rigid object at once for speeding up + for empty, rigid_obj in self.__empty_parent_map.items(): + matrix_world = empty.matrix_world + empty.parent = rigid_obj + empty.matrix_world = matrix_world + self.__empty_parent_map = None + + arm = self.armature() + if arm: + for p_bone in arm.pose.bones: + c = p_bone.constraints.get("mmd_tools_rigid_track", None) + if c: + c.mute = False + + def updateRigid(self, rigid_obj: bpy.types.Object, collision_margin: float): + assert rigid_obj.mmd_type == "RIGID_BODY" + rb = rigid_obj.rigid_body + if rb is None: + return + + rigid = rigid_obj.mmd_rigid + rigid_type = int(rigid.type) + relation = rigid_obj.constraints["mmd_tools_rigid_parent"] + + if relation.target is None: + relation.target = self.armature() + + arm = relation.target + if relation.subtarget not in arm.pose.bones: + bone_name = "" + else: + bone_name = relation.subtarget + + if rigid_type == rigid_body.MODE_STATIC: + rb.kinematic = True + else: + rb.kinematic = False + + if collision_margin == 0.0: + rb.use_margin = False + else: + rb.use_margin = True + rb.collision_margin = collision_margin + + if arm is not None and bone_name != "": + target_bone = arm.pose.bones[bone_name] + + if rigid_type == rigid_body.MODE_STATIC: + m = target_bone.matrix @ target_bone.bone.matrix_local.inverted() + self.__rigid_body_matrix_map[rigid_obj] = m + orig_scale = rigid_obj.scale.copy() + to_matrix_world = rigid_obj.matrix_world @ rigid_obj.matrix_local.inverted() + matrix_world = to_matrix_world @ (m @ rigid_obj.matrix_local) + rigid_obj.parent = arm + rigid_obj.parent_type = "BONE" + rigid_obj.parent_bone = bone_name + rigid_obj.matrix_world = matrix_world + rigid_obj.scale = orig_scale + 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) + t, r, s = (m @ fake_child.matrix_local).decompose() + fake_child.location = t + fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) + + elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + m = target_bone.matrix @ target_bone.bone.matrix_local.inverted() + self.__rigid_body_matrix_map[rigid_obj] = m + t, r, s = (m @ rigid_obj.matrix_local).decompose() + rigid_obj.location = t + rigid_obj.rotation_euler = r.to_euler(rigid_obj.rotation_mode) + 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) + t, r, s = (m @ fake_child.matrix_local).decompose() + fake_child.location = t + fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) + + if "mmd_tools_rigid_track" not in target_bone.constraints: + empty = bpy.data.objects.new(name="mmd_bonetrack", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) + empty.matrix_world = target_bone.matrix + setattr(empty, Props.empty_display_type, "ARROWS") + setattr(empty, Props.empty_display_size, 0.1 * getattr(self.__root, Props.empty_display_size)) + empty.mmd_type = "TRACK_TARGET" + empty.hide_set(True) + empty.parent = self.temporaryGroupObject() + + rigid_obj.mmd_rigid.bone = bone_name + rigid_obj.constraints.remove(relation) + + self.__empty_parent_map[empty] = rigid_obj + + const_type = ("COPY_TRANSFORMS", "COPY_ROTATION")[rigid_type - 1] + const = target_bone.constraints.new(const_type) + const.mute = True + const.name = "mmd_tools_rigid_track" + const.target = empty + else: + empty = target_bone.constraints["mmd_tools_rigid_track"].target + 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) + # re-parenting + rigid_obj.mmd_rigid.bone = bone_name + rigid_obj.constraints.remove(relation) + self.__empty_parent_map[empty] = rigid_obj + # 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) + + rb.collision_shape = rigid.shape + + def __getRigidRange(self, obj): + return (Vector(obj.bound_box[0]) - Vector(obj.bound_box[6])).length + + def __createNonCollisionConstraint(self, nonCollisionJointTable): + total_len = len(nonCollisionJointTable) + if total_len < 1: + return + + start_time = time.time() + logging.debug("-" * 60) + logging.debug(" creating ncc, counts: %d", total_len) + + ncc_obj = bpyutils.createObject(name="ncc", object_data=None) + ncc_obj.location = [0, 0, 0] + setattr(ncc_obj, Props.empty_display_type, "ARROWS") + setattr(ncc_obj, Props.empty_display_size, 0.5 * getattr(self.__root, Props.empty_display_size)) + ncc_obj.mmd_type = "NON_COLLISION_CONSTRAINT" + ncc_obj.hide_render = True + ncc_obj.parent = self.temporaryGroupObject() + + bpy.ops.rigidbody.constraint_add(type="GENERIC") + rb = ncc_obj.rigid_body_constraint + rb.disable_collisions = True + + ncc_objs = bpyutils.duplicateObject(ncc_obj, total_len) + logging.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) + + def buildRigids(self, non_collision_distance_scale, collision_margin): + logging.debug("--------------------------------") + logging.debug(" Build riggings of rigid bodies") + logging.debug("--------------------------------") + rigid_objects = list(self.rigidBodies()) + rigid_object_groups = [[] for i in range(16)] + for i in rigid_objects: + rigid_object_groups[i.mmd_rigid.collision_group_number].append(i) + + jointMap = {} + for joint in self.joints(): + rbc = joint.rigid_body_constraint + if rbc is None: + continue + rbc.disable_collisions = False + jointMap[frozenset((rbc.object1, rbc.object2))] = joint + + logging.info("Creating non collision constraints") + # create non collision constraints + nonCollisionJointTable = [] + non_collision_pairs = set() + rigid_object_cnt = len(rigid_objects) + for obj_a in rigid_objects: + for n, ignore in enumerate(obj_a.mmd_rigid.collision_group_mask): + if not ignore: + continue + for obj_b in rigid_object_groups[n]: + if obj_a == obj_b: + continue + pair = frozenset((obj_a, obj_b)) + if pair in non_collision_pairs: + continue + if pair in jointMap: + joint = jointMap[pair] + joint.rigid_body_constraint.disable_collisions = True + else: + distance = (obj_a.location - obj_b.location).length + if distance < non_collision_distance_scale * (self.__getRigidRange(obj_a) + self.__getRigidRange(obj_b)) * 0.5: + 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) + self.updateRigid(i, collision_margin) + self.__createNonCollisionConstraint(nonCollisionJointTable) + return rigid_objects + + def buildJoints(self): + for i in self.joints(): + rbc = i.rigid_body_constraint + if rbc is None: + continue + m = self.__rigid_body_matrix_map.get(rbc.object1, None) + if m is None: + m = self.__rigid_body_matrix_map.get(rbc.object2, None) + if m is None: + continue + t, r, s = (m @ i.matrix_local).decompose() + i.location = t + i.rotation_euler = r.to_euler(i.rotation_mode) + + def __editPhysicsBones(self, editor: Callable[[bpy.types.EditBone], None], target_modes: Set[str]): + armature_object = self.armature() + + armature: bpy.types.Armature + with bpyutils.edit_object(armature_object) as armature: + 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 + if mmd_rigid.type not in target_modes: + continue + + bone_name: str = mmd_rigid.bone + edit_bone = edit_bones.get(bone_name) + if edit_bone is None: + continue + + editor(edit_bone) + + def disconnectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): + rna_prop_ui.rna_idprop_ui_create(edit_bone, "mmd_bone_use_connect", default=edit_bone.use_connect) + edit_bone.use_connect = False + + self.__editPhysicsBones(editor, {str(MODE_DYNAMIC)}) + + def connectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): + mmd_bone_use_connect_str: Optional[str] = edit_bone.get("mmd_bone_use_connect") + if mmd_bone_use_connect_str is None: + return + + if not edit_bone.use_connect: # wasn't it overwritten? + edit_bone.use_connect = bool(mmd_bone_use_connect_str) + 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 new file mode 100644 index 0000000..aaa707e --- /dev/null +++ b/core/mmd/core/morph.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import re +from typing import TYPE_CHECKING, Tuple, cast + +import bpy + +from .. import bpyutils, utils +from ..bpyutils import FnContext, FnObject, TransformConstraintOp + +if TYPE_CHECKING: + from .model import Model + + +class FnMorph: + def __init__(self, morph, model: "Model"): + self.__morph = morph + self.__rig = model + + @classmethod + def storeShapeKeyOrder(cls, obj, shape_key_names): + if len(shape_key_names) < 1: + return + assert FnContext.get_active_object(FnContext.ensure_context()) == obj + if obj.data.shape_keys is None: + bpy.ops.object.shape_key_add() + + def __move_to_bottom(key_blocks, name): + obj.active_shape_key_index = key_blocks.find(name) + bpy.ops.object.shape_key_move(type="BOTTOM") + + key_blocks = obj.data.shape_keys.key_blocks + for name in shape_key_names: + if name not in key_blocks: + obj.shape_key_add(name=name, from_mix=False) + elif len(key_blocks) > 1: + __move_to_bottom(key_blocks, name) + + @classmethod + def fixShapeKeyOrder(cls, obj, shape_key_names): + if len(shape_key_names) < 1: + return + assert FnContext.get_active_object(FnContext.ensure_context()) == obj + key_blocks = getattr(obj.data.shape_keys, "key_blocks", None) + if key_blocks is None: + return + for name in shape_key_names: + idx = key_blocks.find(name) + if idx < 0: + continue + obj.active_shape_key_index = idx + bpy.ops.object.shape_key_move(type="BOTTOM") + + @staticmethod + def get_morph_slider(rig): + return _MorphSlider(rig) + + @staticmethod + def category_guess(morph): + name_lower = morph.name.lower() + if "mouth" in name_lower: + morph.category = "MOUTH" + elif "eye" in name_lower: + if "brow" in name_lower: + morph.category = "EYEBROW" + else: + morph.category = "EYE" + + @classmethod + def load_morphs(cls, rig): + mmd_root = rig.rootObject().mmd_root + vertex_morphs = mmd_root.vertex_morphs + uv_morphs = mmd_root.uv_morphs + for obj in rig.meshes(): + for kb in getattr(obj.data.shape_keys, "key_blocks", ())[1:]: + if not kb.name.startswith("mmd_") and kb.name not in vertex_morphs: + item = vertex_morphs.add() + item.name = kb.name + item.name_e = kb.name + cls.category_guess(item) + for g, name, x in FnMorph.get_uv_morph_vertex_groups(obj): + if name not in uv_morphs: + item = uv_morphs.add() + item.name = item.name_e = name + item.data_type = "VERTEX_GROUP" + cls.category_guess(item) + + @staticmethod + def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str): + assert isinstance(mesh_object.data, bpy.types.Mesh) + + shape_keys = mesh_object.data.shape_keys + if shape_keys is None: + return + + key_blocks = shape_keys.key_blocks + if key_blocks and shape_key_name in key_blocks: + 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): + assert isinstance(mesh_object.data, bpy.types.Mesh) + + shape_keys = mesh_object.data.shape_keys + if shape_keys is None: + return + + key_blocks = shape_keys.key_blocks + + if src_name not in key_blocks: + return + + if dest_name in key_blocks: + FnObject.mesh_remove_shape_key(mesh_object, key_blocks[dest_name]) + + mesh_object.active_shape_key_index = key_blocks.find(src_name) + mesh_object.show_only_shape_key, last = True, mesh_object.show_only_shape_key + mesh_object.shape_key_add(name=dest_name, from_mix=True) + mesh_object.show_only_shape_key = last + 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"): + 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): + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name): + obj.vertex_groups.remove(vg) + + for vg_name in tuple(i[0].name for i in FnMorph.get_uv_morph_vertex_groups(obj, src_name)): + obj.vertex_groups.active = obj.vertex_groups[vg_name] + with bpy.context.temp_override(object=obj, window=bpy.context.window, region=bpy.context.region): + bpy.ops.object.vertex_group_copy() + obj.vertex_groups.active.name = vg_name.replace(src_name, dest_name) + + @staticmethod + def overwrite_bone_morphs_from_action_pose(armature_object): + armature = armature_object.id_data + + # Use animation_data and action instead of action_pose + if armature.animation_data is None or armature.animation_data.action is None: + logging.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name) + return + + action = armature.animation_data.action + pose_markers = action.pose_markers + + if not pose_markers: + return + + root = armature_object.parent + mmd_root = root.mmd_root + bone_morphs = mmd_root.bone_morphs + + utils.selectAObject(armature_object) + original_mode = bpy.context.object.mode + bpy.ops.object.mode_set(mode="POSE") + try: + for index, pose_marker in enumerate(pose_markers): + bone_morph = next(iter([m for m in bone_morphs if m.name == pose_marker.name]), None) + if bone_morph is None: + bone_morph = bone_morphs.add() + bone_morph.name = pose_marker.name + + bpy.ops.pose.select_all(action="SELECT") + bpy.ops.pose.transforms_clear() + + frame = pose_marker.frame + bpy.context.scene.frame_set(int(frame)) + + mmd_root.active_morph = bone_morphs.find(bone_morph.name) + bpy.ops.mmd_tools.apply_bone_morph() + + bpy.ops.pose.transforms_clear() + + finally: + bpy.ops.object.mode_set(mode=original_mode) + utils.selectAObject(root) + + @staticmethod + def clean_uv_morph_vertex_groups(obj): + # remove empty vertex groups of uv morphs + vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)} + vertex_groups = obj.vertex_groups + for v in obj.data.vertices: + for x in v.groups: + if x.group in vg_indices and x.weight > 0: + vg_indices.remove(x.group) + for i in sorted(vg_indices, reverse=True): + vg = vertex_groups[i] + m = obj.modifiers.get("mmd_bind%s" % hash(vg.name), None) + if m: + obj.modifiers.remove(m) + vertex_groups.remove(vg) + + @staticmethod + def get_uv_morph_offset_map(obj, morph): + offset_map = {} # offset_map[vertex_index] = offset_xyzw + if morph.data_type == "VERTEX_GROUP": + scale = morph.vertex_group_scale + axis_map = {g.index: x for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph.name)} + for v in obj.data.vertices: + i = v.index + for x in v.groups: + if x.group in axis_map and x.weight > 0: + axis, weight = axis_map[x.group], x.weight + d = offset_map.setdefault(i, [0, 0, 0, 0]) + d["XYZW".index(axis[1])] += -weight * scale if axis[0] == "-" else weight * scale + else: + for val in morph.data: + i = val.index + if i in offset_map: + offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset)] + else: + offset_map[i] = val.offset + return offset_map + + @staticmethod + def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"): + vertex_groups = obj.vertex_groups + morph_name = getattr(morph, "name", None) + if offset_axes: + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph_name, offset_axes): + vertex_groups.remove(vg) + if not morph_name or not offsets: + return + + axis_indices = tuple("XYZW".index(x) for x in offset_axes) or tuple(range(4)) + offset_map = FnMorph.get_uv_morph_offset_map(obj, morph) if offset_axes else {} + for data in offsets: + idx, offset = data.index, data.offset + for i in axis_indices: + offset_map.setdefault(idx, [0, 0, 0, 0])[i] += round(offset[i], 5) + + max_value = max(max(abs(x) for x in v) for v in offset_map.values() or ([0],)) + scale = morph.vertex_group_scale = max(abs(morph.vertex_group_scale), max_value) + for idx, offset in offset_map.items(): + for val, axis in zip(offset, "XYZW"): + if abs(val) > 1e-4: + vg_name = "UV_{0}{1}{2}".format(morph_name, "-" if val < 0 else "+", axis) + vg = vertex_groups.get(vg_name, None) or vertex_groups.new(name=vg_name) + vg.add(index=[idx], weight=abs(val) / scale, type="REPLACE") + + def update_mat_related_mesh(self, new_mesh=None): + for offset in self.__morph.data: + # Use the new_mesh if provided + meshObj = new_mesh + if new_mesh is None: + # Try to find the mesh by material name + meshObj = self.__rig.findMesh(offset.material) + + if meshObj is None: + # Given this point we need to loop through all the meshes + for mesh in self.__rig.meshes(): + if mesh.data.materials.find(offset.material) >= 0: + meshObj = mesh + break + + # Finally update the reference + if meshObj is not None: + offset.related_mesh = meshObj.data.name + + @staticmethod + def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object): + """Clean duplicated material_morphs and data from mmd_root_object.mmd_root.material_morphs[].data[]""" + mmd_root = mmd_root_object.mmd_root + + def morph_data_equals(l, r) -> bool: + return ( + l.related_mesh_data == r.related_mesh_data + and l.offset_type == r.offset_type + and l.material == r.material + and all(a == b for a, b in zip(l.diffuse_color, r.diffuse_color)) + and all(a == b for a, b in zip(l.specular_color, r.specular_color)) + and l.shininess == r.shininess + and all(a == b for a, b in zip(l.ambient_color, r.ambient_color)) + and all(a == b for a, b in zip(l.edge_color, r.edge_color)) + and l.edge_weight == r.edge_weight + and all(a == b for a, b in zip(l.texture_factor, r.texture_factor)) + and all(a == b for a, b in zip(l.sphere_texture_factor, r.sphere_texture_factor)) + and all(a == b for a, b in zip(l.toon_texture_factor, r.toon_texture_factor)) + ) + + def morph_equals(l, r) -> 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[] + for material_morph in mmd_root.material_morphs: + save_materil_morph_datas = [] + remove_material_morph_data_indices = [] + for index, material_morph_data in enumerate(material_morph.data): + if any(morph_data_equals(material_morph_data, saved_material_morph_data) for saved_material_morph_data in save_materil_morph_datas): + remove_material_morph_data_indices.append(index) + continue + save_materil_morph_datas.append(material_morph_data) + + for index in reversed(remove_material_morph_data_indices): + material_morph.data.remove(index) + + # Mark duplicated mmd_root.material_morphs[] + save_material_morphs = [] + remove_material_morph_names = [] + for material_morph in sorted(mmd_root.material_morphs, key=lambda m: m.name): + if any(morph_equals(material_morph, saved_material_morph) for saved_material_morph in save_material_morphs): + remove_material_morph_names.append(material_morph.name) + continue + + save_material_morphs.append(material_morph) + + # Remove marked mmd_root.material_morphs[] + for material_morph_name in remove_material_morph_names: + mmd_root.material_morphs.remove(mmd_root.material_morphs.find(material_morph_name)) + + +class _MorphSlider: + def __init__(self, model: "Model"): + self.__rig = model + + def placeholder(self, create=False, binded=False): + rig = self.__rig + root = rig.rootObject() + obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None) + if create and obj is None: + obj = bpy.data.objects.new(name=".placeholder", object_data=bpy.data.meshes.new(".placeholder")) + obj.mmd_type = "PLACEHOLDER" + obj.parent = root + FnContext.link_object(FnContext.ensure_context(), obj) + if obj and obj.data.shape_keys is None: + key = obj.shape_key_add(name="--- morph sliders ---") + key.mute = True + obj.active_shape_key_index = 0 + if binded and obj and obj.data.shape_keys.key_blocks[0].mute: + return None + return obj + + @property + def dummy_armature(self): + obj = self.placeholder() + return self.__dummy_armature(obj) if obj else None + + def __dummy_armature(self, obj, create=False): + arm = next((x for x in obj.children if x.mmd_type == "PLACEHOLDER" and x.type == "ARMATURE"), None) + if create and arm is None: + arm = bpy.data.objects.new(name=".dummy_armature", object_data=bpy.data.armatures.new(name=".dummy_armature")) + arm.mmd_type = "PLACEHOLDER" + arm.parent = obj + FnContext.link_object(FnContext.ensure_context(), arm) + + from .bone import FnBone + + FnBone.setup_special_bone_collections(arm) + return arm + + def get(self, morph_name): + obj = self.placeholder() + if obj is None: + return None + key_blocks = obj.data.shape_keys.key_blocks + if key_blocks[0].mute: + return None + return key_blocks.get(morph_name, None) + + def create(self): + self.__rig.loadMorphs() + obj = self.placeholder(create=True) + self.__load(obj, self.__rig.rootObject().mmd_root) + return obj + + def __load(self, obj, mmd_root): + attr_list = ("group", "vertex", "bone", "uv", "material") + morph_sliders = obj.data.shape_keys.key_blocks + for m in (x for attr in attr_list for x in getattr(mmd_root, attr + "_morphs", ())): + name = m.name + # if name[-1] == '\\': # fix driver's bug??? + # m.name = name = name + ' ' + if name and name not in morph_sliders: + obj.shape_key_add(name=name, from_mix=False) + + @staticmethod + def __driver_variables(id_data, path, index=-1): + d = id_data.driver_add(path, index) + variables = d.driver.variables + for x in variables: + variables.remove(x) + return d.driver, variables + + @staticmethod + def __add_single_prop(variables, id_obj, data_path, prefix): + var = variables.new() + var.name = f"{prefix}{len(variables)}" + var.type = "SINGLE_PROP" + target = var.targets[0] + target.id_type = "OBJECT" + target.id = id_obj + target.data_path = data_path + return var + + @staticmethod + def __shape_key_driver_check(key_block, resolve_path=False): + if resolve_path: + try: + key_block.id_data.path_resolve(key_block.path_from_id()) + except ValueError: + return False + if not key_block.id_data.animation_data: + return True + d = key_block.id_data.animation_data.drivers.find(key_block.path_from_id("value")) + if isinstance(d, int): # for Blender 2.76 or older + data_path = key_block.path_from_id("value") + 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): + from math import ceil, floor + + names_in_use = names_in_use or {} + rig = self.__rig + morph_sliders = self.placeholder() + morph_sliders = morph_sliders.data.shape_keys.key_blocks if morph_sliders else {} + for mesh_object in rig.meshes(): + for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast(Tuple[bpy.types.ShapeKey], ())): + if kb.name in names_in_use: + continue + + if kb.name.startswith("mmd_bind"): + kb.driver_remove("value") + ms = morph_sliders[kb.relative_key.name] + kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, floor(ms.value)), max(ms.slider_max, ceil(ms.value)) + kb.relative_key.value = ms.value + kb.relative_key.mute = False + FnObject.mesh_remove_shape_key(mesh_object, kb) + + elif kb.name in morph_sliders and self.__shape_key_driver_check(kb): + ms = morph_sliders[kb.name] + kb.driver_remove("value") + kb.slider_min, kb.slider_max = min(ms.slider_min, floor(kb.value)), max(ms.slider_max, ceil(kb.value)) + + for m in mesh_object.modifiers: # uv morph + if m.name.startswith("mmd_bind") and m.name not in names_in_use: + mesh_object.modifiers.remove(m) + + from .shader import _MaterialMorph + + for m in rig.materials(): + if m and m.node_tree: + for n in sorted((x for x in m.node_tree.nodes if x.name.startswith("mmd_bind")), key=lambda x: -x.location[0]): + _MaterialMorph.reset_morph_links(n) + m.node_tree.nodes.remove(n) + + attributes = set(TransformConstraintOp.min_max_attributes("LOCATION", "to")) + attributes |= set(TransformConstraintOp.min_max_attributes("ROTATION", "to")) + for b in rig.armature().pose.bones: + for c in b.constraints: + if c.name.startswith("mmd_bind") and c.name[:-4] not in names_in_use: + for attr in attributes: + c.driver_remove(attr) + b.constraints.remove(c) + + def unbind(self): + mmd_root = self.__rig.rootObject().mmd_root + + # after unbind, the weird lag problem will disappear. + mmd_root.morph_panel_show_settings = True + + for m in mmd_root.bone_morphs: + for d in m.data: + d.name = "" + for m in mmd_root.material_morphs: + for d in m.data: + d.name = "" + obj = self.placeholder() + if obj: + obj.data.shape_keys.key_blocks[0].mute = True + arm = self.__dummy_armature(obj) + if arm: + for b in arm.pose.bones: + if b.name.startswith("mmd_bind"): + b.driver_remove("location") + b.driver_remove("rotation_quaternion") + self.__cleanup() + + def bind(self): + rig = self.__rig + root = rig.rootObject() + armObj = rig.armature() + mmd_root = root.mmd_root + + # hide detail to avoid weird lag problem + mmd_root.morph_panel_show_settings = False + + obj = self.create() + arm = self.__dummy_armature(obj, create=True) + morph_sliders = obj.data.shape_keys.key_blocks + + # data gathering + group_map = {} + + shape_key_map = {} + uv_morph_map = {} + for mesh_object in rig.meshes(): + mesh_object.show_only_shape_key = False + key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ()) + for kb in key_blocks: + kb_name = kb.name + if kb_name not in morph_sliders: + continue + + if self.__shape_key_driver_check(kb, resolve_path=True): + name_bind, kb_bind = kb_name, kb + else: + name_bind = "mmd_bind%s" % hash(morph_sliders[kb_name]) + if name_bind not in key_blocks: + mesh_object.shape_key_add(name=name_bind, from_mix=False) + kb_bind = key_blocks[name_bind] + kb_bind.relative_key = kb + kb_bind.slider_min = -10 + kb_bind.slider_max = 10 + + data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"') + groups = [] + shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups)) + group_map.setdefault(("vertex_morphs", kb_name), []).append(groups) + + uv_layers = [l.name for l in mesh_object.data.uv_layers if not l.name.startswith("_")] + uv_layers += [""] * (5 - len(uv_layers)) + for vg, morph_name, axis in FnMorph.get_uv_morph_vertex_groups(mesh_object): + morph = mmd_root.uv_morphs.get(morph_name, None) + if morph is None or morph.data_type != "VERTEX_GROUP": + continue + + uv_layer = "_" + uv_layers[morph.uv_index] if axis[1] in "ZW" else uv_layers[morph.uv_index] + if uv_layer not in mesh_object.data.uv_layers: + continue + + name_bind = "mmd_bind%s" % hash(vg.name) + 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 + mod.axis_u, mod.axis_v = ("Y", "X") if axis[1] in "YW" else ("X", "Y") + mod.uv_layer = uv_layer + name_bind = "mmd_bind%s" % hash(morph_name) + mod.object_from = mod.object_to = arm + if axis[0] == "-": + mod.bone_from, mod.bone_to = "mmd_bind_ctrl_base", name_bind + else: + mod.bone_from, mod.bone_to = name_bind, "mmd_bind_ctrl_base" + + bone_offset_map = {} + with bpyutils.edit_object(arm) as data: + from .bone import FnBone + + edit_bones = data.edit_bones + + def __get_bone(name, parent): + b = edit_bones.get(name, None) or edit_bones.new(name=name) + b.head = (0, 0, 0) + b.tail = (0, 0, 1) + b.use_deform = False + b.parent = parent + return b + + for m in mmd_root.bone_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + for d in m.data: + if not d.bone: + d.name = "" + continue + d.name = name_bind = f"mmd_bind{hash(d)}" + b = FnBone.set_edit_bone_to_shadow(__get_bone(name_bind, None)) + groups = [] + bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups) + group_map.setdefault(("bone_morphs", m.name), []).append(groups) + + ctrl_base = FnBone.set_edit_bone_to_dummy(__get_bone("mmd_bind_ctrl_base", None)) + for m in mmd_root.uv_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + 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 = [] + 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.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 = {} + for m in mmd_root.material_morphs: + morph_name = m.name.replace('"', '\\"') + data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + groups = [] + group_map.setdefault(("material_morphs", m.name), []).append(groups) + material_offset_map.setdefault("group_dict", {})[m.name] = (data_path, groups) + for d in m.data: + d.name = name_bind = f"mmd_bind{hash(d)}" + # add '#' before material name to avoid conflict with group_dict + table = material_offset_map.setdefault("#" + d.material, ([], [])) + table[1 if d.offset_type == "ADD" else 0].append((m.name, d, name_bind)) + + 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) + morph_name = m.name.replace('"', '\\"') + morph_path = f'data.shape_keys.key_blocks["{morph_name}"].value' + for d in m.data: + data_name = d.name.replace('"', '\\"') + factor_path = f'mmd_root.group_morphs["{morph_name}"].data["{data_name}"].factor' + for groups in group_map.get((d.morph_type, d.name), ()): + groups.append((m.name, morph_path, factor_path)) + + self.__cleanup(shape_key_map.keys() | bone_offset_map.keys() | uv_morph_map.keys()) + + def __config_groups(variables, expression, groups): + for g_name, morph_path, factor_path in groups: + var = self.__add_single_prop(variables, obj, morph_path, "g") + fvar = self.__add_single_prop(variables, root, factor_path, "w") + expression = f"{expression}+{var.name}*{fvar.name}" + return expression + + # vertex morphs + for kb_bind, morph_data_path, groups in (i for l in shape_key_map.values() for i in l): + driver, variables = self.__driver_variables(kb_bind, "value") + var = self.__add_single_prop(variables, obj, morph_data_path, "v") + if kb_bind.name.startswith("mmd_bind"): + driver.expression = f"-({__config_groups(variables, var.name, groups)})" + kb_bind.relative_key.mute = True + else: + driver.expression = __config_groups(variables, var.name, groups) + kb_bind.mute = False + + # bone morphs + def __config_bone_morph(constraints, map_type, attributes, val, val_str): + c_name = f"mmd_bind{hash(data)}.{map_type[:3]}" + c = TransformConstraintOp.create(constraints, c_name, map_type) + TransformConstraintOp.update_min_max(c, val, None) + c.show_expanded = False + c.target = arm + c.subtarget = bname + for attr in attributes: + driver, variables = self.__driver_variables(armObj, c.path_from_id(attr)) + var = self.__add_single_prop(variables, obj, morph_data_path, "b") + expression = __config_groups(variables, var.name, groups) + sign = "-" if attr.startswith("to_min") else "" + driver.expression = f"{sign}{val_str}*({expression})" + + from math import pi + + attributes_rot = TransformConstraintOp.min_max_attributes("ROTATION", "to") + attributes_loc = TransformConstraintOp.min_max_attributes("LOCATION", "to") + for morph_name, data, bname, morph_data_path, groups in bone_offset_map.values(): + b = arm.pose.bones[bname] + b.location = data.location + b.rotation_quaternion = data.rotation.__class__(*data.rotation.to_axis_angle()) # Fix for consistency + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + pb = armObj.pose.bones[data.bone] + __config_bone_morph(pb.constraints, "ROTATION", attributes_rot, pi, "pi") + __config_bone_morph(pb.constraints, "LOCATION", attributes_loc, 100, "100") + + # uv morphs + # HACK: workaround for Blender 2.80+, data_path can't be properly detected (Save & Reopen file also works) + root.parent, root.parent, root.matrix_parent_inverse = arm, root.parent, root.matrix_parent_inverse.copy() + b = arm.pose.bones["mmd_bind_ctrl_base"] + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + for bname, data_path, scale_path, groups in (i for l in uv_morph_map.values() for i in l): + b = arm.pose.bones[bname] + b.is_mmd_shadow_bone = True + b.mmd_shadow_bone_type = "BIND" + driver, variables = self.__driver_variables(b, "location", index=0) + var = self.__add_single_prop(variables, obj, data_path, "u") + fvar = self.__add_single_prop(variables, root, scale_path, "s") + driver.expression = f"({__config_groups(variables, var.name, groups)})*{fvar.name}" + + # material morphs + from .shader import _MaterialMorph + + group_dict = material_offset_map.get("group_dict", {}) + + def __config_material_morph(mat, morph_list): + nodes = _MaterialMorph.setup_morph_nodes(mat, tuple(x[1] for x in morph_list)) + for (morph_name, data, name_bind), node in zip(morph_list, nodes): + node.label, node.name = morph_name, name_bind + data_path, groups = group_dict[morph_name] + driver, variables = self.__driver_variables(mat.node_tree, node.inputs[0].path_from_id("default_value")) + var = self.__add_single_prop(variables, obj, data_path, "m") + driver.expression = "%s" % __config_groups(variables, var.name, groups) + + 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.") + mul_list, add_list = [], [] + else: + mat_name = "#" + mat.name + mul_list, add_list = material_offset_map.get(mat_name, ([], [])) + morph_list = tuple(mul_all + mul_list + add_all + add_list) + __config_material_morph(mat, morph_list) + mat_edge = bpy.data.materials.get("mmd_edge." + mat.name, None) + if mat_edge: + __config_material_morph(mat_edge, morph_list) + + morph_sliders[0].mute = False + + +class MigrationFnMorph: + @staticmethod + def update_mmd_morph(): + from .material import FnMaterial + + for root in bpy.data.objects: + if root.mmd_type != "ROOT": + continue + + for mat_morph in root.mmd_root.material_morphs: + for morph_data in mat_morph.data: + if morph_data.material_data is not None: + # SUPPORT_UNTIL: 5 LTS + # The material_id is also no longer used, but for compatibility with older version mmd_tools, keep it. + if "material_id" not in morph_data.material_data.mmd_material or "material_id" not in morph_data or morph_data.material_data.mmd_material["material_id"] == morph_data["material_id"]: + # In the new version, the related_mesh property is no longer used. + # Explicitly remove this property to avoid misuse. + if "related_mesh" in morph_data: + del morph_data["related_mesh"] + continue + + else: + # Compat case. The new version mmd_tools saved. And old version mmd_tools edit. Then new version mmd_tools load again. + # Go update path. + pass + + morph_data.material_data = None + if "material_id" in morph_data: + mat_id = morph_data["material_id"] + if mat_id != -1: + fnMat = FnMaterial.from_material_id(mat_id) + if fnMat: + morph_data.material_data = fnMat.material + else: + morph_data["material_id"] = -1 + + morph_data.related_mesh_data = None + if "related_mesh" in morph_data: + related_mesh = morph_data["related_mesh"] + del morph_data["related_mesh"] + if related_mesh != "" and related_mesh in bpy.data.meshes: + morph_data.related_mesh_data = bpy.data.meshes[related_mesh] + + @staticmethod + def ensure_material_id_not_conflict(): + mat_ids_set = set() + + # The reference library properties cannot be modified and bypassed in advance. + need_update_mat = [] + for mat in bpy.data.materials: + if mat.mmd_material.material_id < 0: + continue + if mat.library is not None: + mat_ids_set.add(mat.mmd_material.material_id) + else: + need_update_mat.append(mat) + + for mat in need_update_mat: + if mat.mmd_material.material_id in mat_ids_set: + mat.mmd_material.material_id = max(mat_ids_set) + 1 + mat_ids_set.add(mat.mmd_material.material_id) + + @staticmethod + def compatible_with_old_version_mmd_tools(): + MigrationFnMorph.ensure_material_id_not_conflict() + + for root in bpy.data.objects: + if root.mmd_type != "ROOT": + continue + + for mat_morph in root.mmd_root.material_morphs: + for morph_data in mat_morph.data: + morph_data["related_mesh"] = morph_data.related_mesh + + if morph_data.material_data is None: + morph_data.material_id = -1 + else: + morph_data.material_id = morph_data.material_data.mmd_material.material_id diff --git a/core/mmd/core/pmx/__init__.py b/core/mmd/core/pmx/__init__.py new file mode 100644 index 0000000..7de70bd --- /dev/null +++ b/core/mmd/core/pmx/__init__.py @@ -0,0 +1,1625 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import os +import struct + + +class InvalidFileError(Exception): + pass +class UnsupportedVersionError(Exception): + pass + +class FileStream: + def __init__(self, path, file_obj, pmx_header): + self.__path = path + self.__file_obj = file_obj + self.__header = pmx_header + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def path(self): + return self.__path + + def header(self): + if self.__header is None: + raise Exception + return self.__header + + def setHeader(self, pmx_header): + self.__header = pmx_header + + def close(self): + if self.__file_obj is not None: + logging.debug('close the file("%s")', self.__path) + self.__file_obj.close() + self.__file_obj = None + +class FileReadStream(FileStream): + def __init__(self, path, pmx_header=None): + self.__fin = open(path, 'rb') + FileStream.__init__(self, path, self.__fin, pmx_header) + + def __readIndex(self, size, typedict): + index = None + if size in typedict : + index, = struct.unpack(typedict[size], self.__fin.read(size)) + else: + raise ValueError('invalid data size %s'%str(size)) + return index + + def __readSignedIndex(self, size): + return self.__readIndex(size, { 1 :"'%self.charset + +class Coordinate: + """ """ + def __init__(self, xAxis, zAxis): + self.x_axis = xAxis + self.z_axis = zAxis + +class Header: + PMX_SIGN = b'PMX ' + VERSION = 2.0 + def __init__(self, model=None): + self.sign = self.PMX_SIGN + self.version = 0 + + self.encoding = Encoding('utf-16-le') + self.additional_uvs = 0 + + self.vertex_index_size = 1 + self.texture_index_size = 1 + self.material_index_size = 1 + self.bone_index_size = 1 + self.morph_index_size = 1 + self.rigid_index_size = 1 + + if model is not None: + self.updateIndexSizes(model) + + def updateIndexSizes(self, model): + self.vertex_index_size = self.__getIndexSize(len(model.vertices), False) + self.texture_index_size = self.__getIndexSize(len(model.textures), True) + self.material_index_size = self.__getIndexSize(len(model.materials), True) + self.bone_index_size = self.__getIndexSize(len(model.bones), True) + self.morph_index_size = self.__getIndexSize(len(model.morphs), True) + self.rigid_index_size = self.__getIndexSize(len(model.rigids), True) + + @staticmethod + def __getIndexSize(num, signed): + s = 1 + if signed: + s = 2 + if (1<<8)/s > num: + return 1 + elif (1<<16)/s > num: + return 2 + else: + return 4 + + def load(self, fs): + logging.info('loading pmx header information...') + self.sign = fs.readBytes(4) + logging.debug('File signature is %s', self.sign) + if self.sign[:3] != self.PMX_SIGN[:3]: + logging.info('File signature is invalid') + logging.error('This file is unsupported format, or corrupt file.') + raise InvalidFileError('File signature is invalid.') + self.version = fs.readFloat() + logging.info('pmx format version: %f', self.version) + if self.version != self.VERSION: + logging.error('PMX version %.1f is unsupported', self.version) + raise UnsupportedVersionError('unsupported PMX version: %.1f'%self.version) + if fs.readByte() != 8 or self.sign[3] != self.PMX_SIGN[3]: + logging.warning(' * This file might be corrupted.') + self.encoding = Encoding(fs.readByte()) + self.additional_uvs = fs.readByte() + self.vertex_index_size = fs.readByte() + self.texture_index_size = fs.readByte() + self.material_index_size = fs.readByte() + self.bone_index_size = fs.readByte() + self.morph_index_size = fs.readByte() + self.rigid_index_size = fs.readByte() + + logging.info('----------------------------') + logging.info('pmx header information') + logging.info('----------------------------') + logging.info('pmx version: %.1f', self.version) + logging.info('encoding: %s', str(self.encoding)) + logging.info('number of uvs: %d', self.additional_uvs) + logging.info('vertex index size: %d byte(s)', self.vertex_index_size) + logging.info('texture index: %d byte(s)', self.texture_index_size) + logging.info('material index: %d byte(s)', self.material_index_size) + logging.info('bone index: %d byte(s)', self.bone_index_size) + logging.info('morph index: %d byte(s)', self.morph_index_size) + logging.info('rigid index: %d byte(s)', self.rigid_index_size) + logging.info('----------------------------') + + def save(self, fs): + fs.writeBytes(self.PMX_SIGN) + fs.writeFloat(self.VERSION) + fs.writeByte(8) + fs.writeByte(self.encoding.index) + fs.writeByte(self.additional_uvs) + fs.writeByte(self.vertex_index_size) + fs.writeByte(self.texture_index_size) + fs.writeByte(self.material_index_size) + fs.writeByte(self.bone_index_size) + fs.writeByte(self.morph_index_size) + fs.writeByte(self.rigid_index_size) + + def __repr__(self): + return '
'%( + str(self.encoding), + self.additional_uvs, + self.vertex_index_size, + self.texture_index_size, + self.material_index_size, + self.bone_index_size, + self.morph_index_size, + self.rigid_index_size, + ) + +class Model: + def __init__(self): + self.filepath = '' + self.header = None + + self.name = '' + self.name_e = '' + self.comment = '' + self.comment_e = '' + + self.vertices = [] + self.faces = [] + self.textures = [] + self.materials = [] + self.bones = [] + self.morphs = [] + + self.display = [] + dsp_root = Display() + dsp_root.isSpecial = True + dsp_root.name = 'Root' + dsp_root.name_e = 'Root' + self.display.append(dsp_root) + dsp_face = Display() + dsp_face.isSpecial = True + dsp_face.name = '表情' + dsp_face.name_e = 'Facial' + self.display.append(dsp_face) + + self.rigids = [] + self.joints = [] + + def load(self, fs): + self.filepath = fs.path() + self.header = fs.header() + + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.comment = fs.readStr() + self.comment_e = fs.readStr() + + logging.info('Model name: %s', self.name) + logging.info('Model name(english): %s', self.name_e) + logging.info('Comment:%s', self.comment) + logging.info('Comment(english):%s', self.comment_e) + + logging.info('') + logging.info('------------------------------') + logging.info('Load Vertices') + logging.info('------------------------------') + num_vertices = fs.readInt() + self.vertices = [] + for i in range(num_vertices): + v = Vertex() + v.load(fs) + self.vertices.append(v) + logging.info('----- Loaded %d vertices', len(self.vertices)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Faces') + logging.info('------------------------------') + num_faces = fs.readInt() + self.faces = [] + for i in range(int(num_faces/3)): + f1 = fs.readVertexIndex() + f2 = fs.readVertexIndex() + f3 = fs.readVertexIndex() + self.faces.append((f3, f2, f1)) + logging.info(' Load %d faces', len(self.faces)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Textures') + logging.info('------------------------------') + num_textures = fs.readInt() + self.textures = [] + for i in range(num_textures): + t = Texture() + t.load(fs) + self.textures.append(t) + logging.info('Texture %d: %s', i, t.path) + logging.info(' ----- Loaded %d textures', len(self.textures)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Materials') + logging.info('------------------------------') + num_materials = fs.readInt() + self.materials = [] + for i in range(num_materials): + m = Material() + m.load(fs, num_textures) + self.materials.append(m) + + logging.info('Material %d: %s', i, m.name) + logging.debug(' Name(english): %s', m.name_e) + logging.debug(' Comment: %s', m.comment) + logging.debug(' Vertex Count: %d', m.vertex_count) + logging.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse) + logging.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular) + logging.debug(' Shininess: %f', m.shininess) + logging.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient) + logging.debug(' Double Sided: %s', str(m.is_double_sided)) + logging.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow)) + logging.debug(' Self Shadow: %s', str(m.enabled_self_shadow)) + logging.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map)) + logging.debug(' Edge: %s', str(m.enabled_toon_edge)) + logging.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color) + logging.debug(' Edge Size: %.2f', m.edge_size) + if m.texture != -1: + logging.debug(' Texture Index: %d', m.texture) + else: + logging.debug(' Texture: None') + if m.sphere_texture != -1: + logging.debug(' Sphere Texture Index: %d', m.sphere_texture) + logging.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode) + else: + logging.debug(' Sphere Texture: None') + logging.debug('') + + logging.info('----- Loaded %d materials.', len(self.materials)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Bones') + logging.info('------------------------------') + num_bones = fs.readInt() + self.bones = [] + for i in range(num_bones): + b = Bone() + b.load(fs) + self.bones.append(b) + + logging.info('Bone %d: %s', i, b.name) + logging.debug(' Name(english): %s', b.name_e) + logging.debug(' Location: (%f, %f, %f)', *b.location) + logging.debug(' displayConnection: %s', str(b.displayConnection)) + logging.debug(' Parent: %s', str(b.parent)) + logging.debug(' Transform Order: %s', str(b.transform_order)) + logging.debug(' Rotatable: %s', str(b.isRotatable)) + logging.debug(' Movable: %s', str(b.isMovable)) + logging.debug(' Visible: %s', str(b.visible)) + logging.debug(' Controllable: %s', str(b.isControllable)) + logging.debug(' Additional Location: %s', str(b.hasAdditionalLocation)) + logging.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate)) + if b.additionalTransform is not None: + logging.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform) + logging.debug(' IK: %s', str(b.isIK)) + if b.isIK: + logging.debug(' Unit Angle: %f', b.rotationConstraint) + logging.debug(' Target: %d', b.target) + for j, link in enumerate(b.ik_links): + logging.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle)) + logging.debug('') + logging.info('----- Loaded %d bones.', len(self.bones)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Morphs') + logging.info('------------------------------') + num_morph = fs.readInt() + self.morphs = [] + display_categories = {0: 'System', 1: 'Eyebrow', 2: 'Eye', 3: 'Mouth', 4: 'Other'} + for i in range(num_morph): + m = Morph.create(fs) + self.morphs.append(m) + + logging.info('%s %d: %s', m.__class__.__name__, i, m.name) + logging.debug(' Name(english): %s', m.name_e) + logging.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category) + logging.debug('') + logging.info('----- Loaded %d morphs.', len(self.morphs)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Display Items') + logging.info('------------------------------') + num_disp = fs.readInt() + self.display = [] + for i in range(num_disp): + d = Display() + d.load(fs) + self.display.append(d) + + logging.info('Display Item %d: %s', i, d.name) + logging.debug(' Name(english): %s', d.name_e) + logging.debug('') + logging.info('----- Loaded %d display items.', len(self.display)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Rigid Bodies') + logging.info('------------------------------') + num_rigid = fs.readInt() + self.rigids = [] + rigid_types = {0: 'Sphere', 1: 'Box', 2: 'Capsule'} + rigid_modes = {0: 'Static', 1: 'Dynamic', 2: 'Dynamic(track to bone)'} + for i in range(num_rigid): + r = Rigid() + r.load(fs) + self.rigids.append(r) + logging.info('Rigid Body %d: %s', i, r.name) + logging.debug(' Name(english): %s', r.name_e) + logging.debug(' Type: %s', rigid_types[r.type]) + logging.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode) + logging.debug(' Related bone: %s', r.bone) + logging.debug(' Collision group: %d', r.collision_group_number) + logging.debug(' Collision group mask: 0x%x', r.collision_group_mask) + logging.debug(' Size: (%f, %f, %f)', *r.size) + logging.debug(' Location: (%f, %f, %f)', *r.location) + logging.debug(' Rotation: (%f, %f, %f)', *r.rotation) + logging.debug(' Mass: %f', r.mass) + logging.debug(' Bounce: %f', r.bounce) + logging.debug(' Friction: %f', r.friction) + logging.debug('') + + logging.info('----- Loaded %d rigid bodies.', len(self.rigids)) + + logging.info('') + logging.info('------------------------------') + logging.info(' Load Joints') + logging.info('------------------------------') + num_joints = fs.readInt() + self.joints = [] + for i in range(num_joints): + j = Joint() + j.load(fs) + self.joints.append(j) + + logging.info('Joint %d: %s', i, j.name) + logging.debug(' Name(english): %s', j.name_e) + logging.debug(' Rigid A: %s', j.src_rigid) + logging.debug(' Rigid B: %s', j.dest_rigid) + logging.debug(' Location: (%f, %f, %f)', *j.location) + logging.debug(' Rotation: (%f, %f, %f)', *j.rotation) + logging.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location)) + logging.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation)) + logging.debug(' Spring: (%f, %f, %f)', *j.spring_constant) + logging.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant) + logging.debug('') + + logging.info('----- Loaded %d joints.', len(self.joints)) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeStr(self.comment) + fs.writeStr(self.comment_e) + + logging.info('''exportings pmx model data... +name: %s +name(english): %s +comment: +%s +comment(english): +%s +''', self.name, self.name_e, self.comment, self.comment_e) + + logging.info('exporting vertices... %d', len(self.vertices)) + fs.writeInt(len(self.vertices)) + for i in self.vertices: + i.save(fs) + logging.info('finished exporting vertices.') + + logging.info('exporting faces... %d', len(self.faces)) + fs.writeInt(len(self.faces)*3) + for f3, f2, f1 in self.faces: + fs.writeVertexIndex(f1) + fs.writeVertexIndex(f2) + fs.writeVertexIndex(f3) + logging.info('finished exporting faces.') + + logging.info('exporting textures... %d', len(self.textures)) + fs.writeInt(len(self.textures)) + for i in self.textures: + i.save(fs) + logging.info('finished exporting textures.') + + logging.info('exporting materials... %d', len(self.materials)) + fs.writeInt(len(self.materials)) + for i in self.materials: + i.save(fs) + logging.info('finished exporting materials.') + + logging.info('exporting bones... %d', len(self.bones)) + fs.writeInt(len(self.bones)) + for i in self.bones: + i.save(fs) + logging.info('finished exporting bones.') + + logging.info('exporting morphs... %d', len(self.morphs)) + fs.writeInt(len(self.morphs)) + for i in self.morphs: + i.save(fs) + logging.info('finished exporting morphs.') + + logging.info('exporting display items... %d', len(self.display)) + fs.writeInt(len(self.display)) + for i in self.display: + i.save(fs) + logging.info('finished exporting display items.') + + logging.info('exporting rigid bodies... %d', len(self.rigids)) + fs.writeInt(len(self.rigids)) + for i in self.rigids: + i.save(fs) + logging.info('finished exporting rigid bodies.') + + logging.info('exporting joints... %d', len(self.joints)) + fs.writeInt(len(self.joints)) + for i in self.joints: + i.save(fs) + logging.info('finished exporting joints.') + logging.info('finished exporting the model.') + + + def __repr__(self): + return ''%( + self.name, + self.name_e, + self.comment, + self.comment_e, + str(self.textures), + ) + +class Vertex: + def __init__(self): + self.co = [0.0, 0.0, 0.0] + self.normal = [0.0, 0.0, 0.0] + self.uv = [0.0, 0.0] + self.additional_uvs = [] + self.weight = None + self.edge_scale = 1 + + def __repr__(self): + return ''%( + str(self.co), + str(self.normal), + str(self.uv), + str(self.additional_uvs), + str(self.weight), + str(self.edge_scale), + ) + + def load(self, fs): + self.co = fs.readVector(3) + self.normal = fs.readVector(3) + self.uv = fs.readVector(2) + self.additional_uvs = [] + for i in range(fs.header().additional_uvs): + self.additional_uvs.append(fs.readVector(4)) + self.weight = BoneWeight() + self.weight.load(fs) + self.edge_scale = fs.readFloat() + + def save(self, fs): + fs.writeVector(self.co) + fs.writeVector(self.normal) + fs.writeVector(self.uv) + for i in self.additional_uvs: + fs.writeVector(i) + for i in range(fs.header().additional_uvs-len(self.additional_uvs)): + fs.writeVector((0,0,0,0)) + self.weight.save(fs) + fs.writeFloat(self.edge_scale) + +class BoneWeightSDEF: + def __init__(self, weight=0, c=None, r0=None, r1=None): + self.weight = weight + self.c = c + self.r0 = r0 + self.r1 = r1 + +class BoneWeight: + BDEF1 = 0 + BDEF2 = 1 + BDEF4 = 2 + SDEF = 3 + + TYPES = [ + (BDEF1, 'BDEF1'), + (BDEF2, 'BDEF2'), + (BDEF4, 'BDEF4'), + (SDEF, 'SDEF'), + ] + + def __init__(self): + self.bones = [] + self.weights = [] + self.type = self.BDEF1 + + def convertIdToName(self, type_id): + t = list(filter(lambda x: x[0]==type_id, self.TYPES)) + if len(t) > 0: + return t[0][1] + else: + return None + + def convertNameToId(self, type_name): + t = list(filter(lambda x: x[1]==type_name, self.TYPES)) + if len(t) > 0: + return t[0][0] + else: + return None + + def load(self, fs): + self.type = fs.readByte() + self.bones = [] + self.weights = [] + + if self.type == self.BDEF1: + self.bones.append(fs.readBoneIndex()) + elif self.type == self.BDEF2: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights.append(fs.readFloat()) + elif self.type == self.BDEF4: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights = fs.readVector(4) + elif self.type == self.SDEF: + self.bones.append(fs.readBoneIndex()) + self.bones.append(fs.readBoneIndex()) + self.weights = BoneWeightSDEF() + self.weights.weight = fs.readFloat() + self.weights.c = fs.readVector(3) + self.weights.r0 = fs.readVector(3) + self.weights.r1 = fs.readVector(3) + else: + raise ValueError('invalid weight type %s'%str(self.type)) + + def save(self, fs): + fs.writeByte(self.type) + if self.type == self.BDEF1: + fs.writeBoneIndex(self.bones[0]) + elif self.type == self.BDEF2: + for i in range(2): + fs.writeBoneIndex(self.bones[i]) + fs.writeFloat(self.weights[0]) + elif self.type == self.BDEF4: + for i in range(4): + fs.writeBoneIndex(self.bones[i]) + for i in range(4): + fs.writeFloat(self.weights[i]) + elif self.type == self.SDEF: + for i in range(2): + fs.writeBoneIndex(self.bones[i]) + if not isinstance(self.weights, BoneWeightSDEF): + raise ValueError + fs.writeFloat(self.weights.weight) + fs.writeVector(self.weights.c) + fs.writeVector(self.weights.r0) + fs.writeVector(self.weights.r1) + else: + raise ValueError('invalid weight type %s'%str(self.type)) + + +class Texture: + def __init__(self): + self.path = '' + + def __repr__(self): + return ''%str(self.path) + + def load(self, fs): + self.path = fs.readStr() + self.path = self.path.replace('\\', os.path.sep) + if not os.path.isabs(self.path): + self.path = os.path.normpath(os.path.join(os.path.dirname(fs.path()), self.path)) + + def save(self, fs): + try: + relPath = os.path.relpath(self.path, os.path.dirname(fs.path())) + except ValueError: + relPath = self.path + relPath = relPath.replace(os.path.sep, '\\') # always save using windows path conventions + logging.info('writing to pmx file the relative texture path: %s', relPath) + fs.writeStr(relPath) + +class SharedTexture(Texture): + def __init__(self): + self.number = 0 + self.prefix = '' + +class Material: + SPHERE_MODE_OFF = 0 + SPHERE_MODE_MULT = 1 + SPHERE_MODE_ADD = 2 + SPHERE_MODE_SUBTEX = 3 + + def __init__(self): + self.name = '' + self.name_e = '' + + self.diffuse = [] + self.specular = [] + self.shininess = 0 + self.ambient = [] + + self.is_double_sided = True + self.enabled_drop_shadow = True + self.enabled_self_shadow_map = True + self.enabled_self_shadow = True + self.enabled_toon_edge = False + + self.edge_color = [] + self.edge_size = 1 + + self.texture = -1 + self.sphere_texture = -1 + self.sphere_texture_mode = 0 + self.is_shared_toon_texture = True + self.toon_texture = 0 + + self.comment = '' + self.vertex_count = 0 + + def __repr__(self): + return ''%( + self.name, + self.name_e, + str(self.diffuse), + str(self.specular), + str(self.shininess), + str(self.ambient), + str(self.is_double_sided), + str(self.enabled_drop_shadow), + str(self.enabled_self_shadow_map), + str(self.enabled_self_shadow), + str(self.enabled_toon_edge), + str(self.edge_color), + str(self.edge_size), + str(self.texture), + str(self.sphere_texture), + str(self.toon_texture), + str(self.comment),) + + def load(self, fs, num_textures): + def __tex_index(index): + return index if 0 <= index < num_textures else -1 + + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.diffuse = fs.readVector(4) + self.specular = fs.readVector(3) + self.shininess = fs.readFloat() + self.ambient = fs.readVector(3) + + flags = fs.readByte() + self.is_double_sided = bool(flags & 1) + self.enabled_drop_shadow = bool(flags & 2) + self.enabled_self_shadow_map = bool(flags & 4) + self.enabled_self_shadow = bool(flags & 8) + self.enabled_toon_edge = bool(flags & 16) + + self.edge_color = fs.readVector(4) + self.edge_size = fs.readFloat() + + self.texture = __tex_index(fs.readTextureIndex()) + self.sphere_texture = __tex_index(fs.readTextureIndex()) + self.sphere_texture_mode = fs.readSignedByte() + + self.is_shared_toon_texture = fs.readSignedByte() + self.is_shared_toon_texture = (self.is_shared_toon_texture == 1) + if self.is_shared_toon_texture: + self.toon_texture = fs.readSignedByte() + else: + self.toon_texture = __tex_index(fs.readTextureIndex()) + + self.comment = fs.readStr() + self.vertex_count = fs.readInt() + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeVector(self.diffuse) + fs.writeVector(self.specular) + fs.writeFloat(self.shininess) + fs.writeVector(self.ambient) + + flags = 0 + flags |= int(self.is_double_sided) + flags |= int(self.enabled_drop_shadow) << 1 + flags |= int(self.enabled_self_shadow_map) << 2 + flags |= int(self.enabled_self_shadow) << 3 + flags |= int(self.enabled_toon_edge) << 4 + fs.writeByte(flags) + + fs.writeVector(self.edge_color) + fs.writeFloat(self.edge_size) + + fs.writeTextureIndex(self.texture) + fs.writeTextureIndex(self.sphere_texture) + fs.writeSignedByte(self.sphere_texture_mode) + + if self.is_shared_toon_texture: + fs.writeSignedByte(1) + fs.writeSignedByte(self.toon_texture) + else: + fs.writeSignedByte(0) + fs.writeTextureIndex(self.toon_texture) + + fs.writeStr(self.comment) + fs.writeInt(self.vertex_count) + + +class Bone: + def __init__(self): + self.name = '' + self.name_e = '' + + self.location = [] + self.parent = None + self.transform_order = 0 + + # 接続先表示方法 + # 座標オフセット(float3)または、boneIndex(int) + self.displayConnection = -1 + + self.isRotatable = True + self.isMovable = True + self.visible = True + self.isControllable = True + + self.isIK = False + + # 回転付与 + self.hasAdditionalRotate = False + + # 移動付与 + self.hasAdditionalLocation = False + + # 回転付与および移動付与の付与量 + self.additionalTransform = None + + # 軸固定 + # 軸ベクトルfloat3 + self.axis = None + + # ローカル軸 + self.localCoordinate = None + + self.transAfterPhis = False + + # 外部親変形 + self.externalTransKey = None + + # 以下IKボーンのみ有効な変数 + self.target = None + self.loopCount = 8 + # IKループ計三時の1回あたりの制限角度(ラジアン) + self.rotationConstraint = 0.03 + + # IKLinkオブジェクトの配列 + self.ik_links = [] + + def __repr__(self): + return ''%( + self.name, + self.name_e,) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.location = fs.readVector(3) + self.parent = fs.readBoneIndex() + self.transform_order = fs.readInt() + + flags = fs.readShort() + if flags & 0x0001: + self.displayConnection = fs.readBoneIndex() + else: + self.displayConnection = fs.readVector(3) + + self.isRotatable = ((flags & 0x0002) != 0) + self.isMovable = ((flags & 0x0004) != 0) + self.visible = ((flags & 0x0008) != 0) + self.isControllable = ((flags & 0x0010) != 0) + + self.isIK = ((flags & 0x0020) != 0) + + self.hasAdditionalRotate = ((flags & 0x0100) != 0) + self.hasAdditionalLocation = ((flags & 0x0200) != 0) + if self.hasAdditionalRotate or self.hasAdditionalLocation: + t = fs.readBoneIndex() + v = fs.readFloat() + self.additionalTransform = (t, v) + else: + self.additionalTransform = None + + + if flags & 0x0400: + self.axis = fs.readVector(3) + else: + self.axis = None + + if flags & 0x0800: + xaxis = fs.readVector(3) + zaxis = fs.readVector(3) + self.localCoordinate = Coordinate(xaxis, zaxis) + else: + self.localCoordinate = None + + self.transAfterPhis = ((flags & 0x1000) != 0) + + if flags & 0x2000: + self.externalTransKey = fs.readInt() + else: + self.externalTransKey = None + + if self.isIK: + self.target = fs.readBoneIndex() + self.loopCount = fs.readInt() + self.rotationConstraint = fs.readFloat() + + iklink_num = fs.readInt() + self.ik_links = [] + for i in range(iklink_num): + link = IKLink() + link.load(fs) + self.ik_links.append(link) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeVector(self.location) + fs.writeBoneIndex(-1 if self.parent is None else self.parent) + fs.writeInt(self.transform_order) + + flags = 0 + flags |= int(isinstance(self.displayConnection, int)) + flags |= int(self.isRotatable) << 1 + flags |= int(self.isMovable) << 2 + flags |= int(self.visible) << 3 + flags |= int(self.isControllable) << 4 + flags |= int(self.isIK) << 5 + + flags |= int(self.hasAdditionalRotate) << 8 + flags |= int(self.hasAdditionalLocation) << 9 + flags |= int(self.axis is not None) << 10 + flags |= int(self.localCoordinate is not None) << 11 + + flags |= int(self.transAfterPhis) << 12 + flags |= int(self.externalTransKey is not None) << 13 + + fs.writeShort(flags) + + if flags & 0x0001: + fs.writeBoneIndex(self.displayConnection) + else: + fs.writeVector(self.displayConnection) + + if self.hasAdditionalRotate or self.hasAdditionalLocation: + fs.writeBoneIndex(self.additionalTransform[0]) + fs.writeFloat(self.additionalTransform[1]) + + if flags & 0x0400: + fs.writeVector(self.axis) + + if flags & 0x0800: + fs.writeVector(self.localCoordinate.x_axis) + fs.writeVector(self.localCoordinate.z_axis) + + if flags & 0x2000: + fs.writeInt(self.externalTransKey) + + if self.isIK: + fs.writeBoneIndex(self.target) + fs.writeInt(self.loopCount) + fs.writeFloat(self.rotationConstraint) + + fs.writeInt(len(self.ik_links)) + for i in self.ik_links: + i.save(fs) + + +class IKLink: + def __init__(self): + self.target = None + self.maximumAngle = None + self.minimumAngle = None + + def __repr__(self): + return ''%(str(self.target)) + + def load(self, fs): + self.target = fs.readBoneIndex() + flag = fs.readByte() + if flag == 1: + self.minimumAngle = fs.readVector(3) + self.maximumAngle = fs.readVector(3) + else: + self.minimumAngle = None + self.maximumAngle = None + + def save(self, fs): + fs.writeBoneIndex(self.target) + if isinstance(self.minimumAngle, (tuple, list)) and isinstance(self.maximumAngle, (tuple, list)): + fs.writeByte(1) + fs.writeVector(self.minimumAngle) + fs.writeVector(self.maximumAngle) + else: + fs.writeByte(0) + +class Morph: + CATEGORY_SYSTEM = 0 + CATEGORY_EYEBROW = 1 + CATEGORY_EYE = 2 + CATEGORY_MOUTH = 3 + CATEGORY_OHTER = 4 + + def __init__(self, name, name_e, category, **kwargs): + self.offsets = [] + self.name = name + self.name_e = name_e + self.category = category + + def __repr__(self): + return ''%(self.name, self.name_e) + + def type_index(self): + raise NotImplementedError + + @staticmethod + def create(fs): + _CLASSES = { + 0: GroupMorph, + 1: VertexMorph, + 2: BoneMorph, + 3: UVMorph, + 4: UVMorph, + 5: UVMorph, + 6: UVMorph, + 7: UVMorph, + 8: MaterialMorph, + } + + name = fs.readStr() + name_e = fs.readStr() + logging.debug('morph: %s', name) + category = fs.readSignedByte() + typeIndex = fs.readSignedByte() + ret = _CLASSES[typeIndex](name, name_e, category, type_index = typeIndex) + ret.load(fs) + return ret + + def load(self, fs): + """ Implement for loading morph data. + """ + raise NotImplementedError + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + fs.writeSignedByte(self.category) + fs.writeSignedByte(self.type_index()) + fs.writeInt(len(self.offsets)) + for i in self.offsets: + i.save(fs) + +class VertexMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 1 + + def load(self, fs): + num = fs.readInt() + for i in range(num): + t = VertexMorphOffset() + t.load(fs) + self.offsets.append(t) + +class VertexMorphOffset: + def __init__(self): + self.index = 0 + self.offset = [] + + def load(self, fs): + self.index = fs.readVertexIndex() + self.offset = fs.readVector(3) + + def save(self, fs): + fs.writeVertexIndex(self.index) + fs.writeVector(self.offset) + +class UVMorph(Morph): + def __init__(self, *args, **kwargs): + self.uv_index = kwargs.get('type_index', 3) - 3 + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return self.uv_index + 3 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = UVMorphOffset() + t.load(fs) + self.offsets.append(t) + +class UVMorphOffset: + def __init__(self): + self.index = 0 + self.offset = [] + + def load(self, fs): + self.index = fs.readVertexIndex() + self.offset = fs.readVector(4) + + def save(self, fs): + fs.writeVertexIndex(self.index) + fs.writeVector(self.offset) + +class BoneMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 2 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = BoneMorphOffset() + t.load(fs) + self.offsets.append(t) + +class BoneMorphOffset: + def __init__(self): + self.index = None + self.location_offset = [] + self.rotation_offset = [] + + def load(self, fs): + self.index = fs.readBoneIndex() + self.location_offset = fs.readVector(3) + self.rotation_offset = fs.readVector(4) + if not any(self.rotation_offset): + self.rotation_offset = (0, 0, 0, 1) + + def save(self, fs): + fs.writeBoneIndex(self.index) + fs.writeVector(self.location_offset) + fs.writeVector(self.rotation_offset) + +class MaterialMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 8 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = MaterialMorphOffset() + t.load(fs) + self.offsets.append(t) + +class MaterialMorphOffset: + TYPE_MULT = 0 + TYPE_ADD = 1 + + def __init__(self): + self.index = 0 + self.offset_type = 0 + self.diffuse_offset = [] + self.specular_offset = [] + self.shininess_offset = 0 + self.ambient_offset = [] + self.edge_color_offset = [] + self.edge_size_offset = [] + self.texture_factor = [] + self.sphere_texture_factor = [] + self.toon_texture_factor = [] + + def load(self, fs): + self.index = fs.readMaterialIndex() + self.offset_type = fs.readSignedByte() + self.diffuse_offset = fs.readVector(4) + self.specular_offset = fs.readVector(3) + self.shininess_offset = fs.readFloat() + self.ambient_offset = fs.readVector(3) + self.edge_color_offset = fs.readVector(4) + self.edge_size_offset = fs.readFloat() + self.texture_factor = fs.readVector(4) + self.sphere_texture_factor = fs.readVector(4) + self.toon_texture_factor = fs.readVector(4) + + def save(self, fs): + fs.writeMaterialIndex(self.index) + fs.writeSignedByte(self.offset_type) + fs.writeVector(self.diffuse_offset) + fs.writeVector(self.specular_offset) + fs.writeFloat(self.shininess_offset) + fs.writeVector(self.ambient_offset) + fs.writeVector(self.edge_color_offset) + fs.writeFloat(self.edge_size_offset) + fs.writeVector(self.texture_factor) + fs.writeVector(self.sphere_texture_factor) + fs.writeVector(self.toon_texture_factor) + +class GroupMorph(Morph): + def __init__(self, *args, **kwargs): + Morph.__init__(self, *args, **kwargs) + + def type_index(self): + return 0 + + def load(self, fs): + self.offsets = [] + num = fs.readInt() + for i in range(num): + t = GroupMorphOffset() + t.load(fs) + self.offsets.append(t) + +class GroupMorphOffset: + def __init__(self): + self.morph = None + self.factor = 0.0 + + def load(self, fs): + self.morph = fs.readMorphIndex() + self.factor = fs.readFloat() + + def save(self, fs): + fs.writeMorphIndex(self.morph) + fs.writeFloat(self.factor) + + +class Display: + def __init__(self): + self.name = '' + self.name_e = '' + + self.isSpecial = False + + self.data = [] + + def __repr__(self): + return ''%( + self.name, + self.name_e, + ) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.isSpecial = (fs.readByte() == 1) + num = fs.readInt() + self.data = [] + for i in range(num): + disp_type = fs.readByte() + index = None + if disp_type == 0: + index = fs.readBoneIndex() + elif disp_type == 1: + index = fs.readMorphIndex() + else: + raise Exception('invalid value.') + self.data.append((disp_type, index)) + logging.debug('the number of display elements: %d', len(self.data)) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeByte(int(self.isSpecial)) + fs.writeInt(len(self.data)) + + for disp_type, index in self.data: + fs.writeByte(disp_type) + if disp_type == 0: + fs.writeBoneIndex(index) + elif disp_type == 1: + fs.writeMorphIndex(index) + else: + raise Exception('invalid value.') + +class Rigid: + TYPE_SPHERE = 0 + TYPE_BOX = 1 + TYPE_CAPSULE = 2 + + MODE_STATIC = 0 + MODE_DYNAMIC = 1 + MODE_DYNAMIC_BONE = 2 + def __init__(self): + self.name = '' + self.name_e = '' + + self.bone = None + self.collision_group_number = 0 + self.collision_group_mask = 0 + + self.type = 0 + self.size = [] + + self.location = [] + self.rotation = [] + + self.mass = 1 + self.velocity_attenuation = [] + self.rotation_attenuation = [] + self.bounce = [] + self.friction = [] + + self.mode = 0 + + def __repr__(self): + return ''%( + self.name, + self.name_e, + ) + + def load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + boneIndex = fs.readBoneIndex() + if boneIndex != -1: + self.bone = boneIndex + else: + self.bone = None + + self.collision_group_number = fs.readSignedByte() + self.collision_group_mask = fs.readUnsignedShort() + + self.type = fs.readSignedByte() + self.size = fs.readVector(3) + + self.location = fs.readVector(3) + self.rotation = fs.readVector(3) + + self.mass = fs.readFloat() + self.velocity_attenuation = fs.readFloat() + self.rotation_attenuation = fs.readFloat() + self.bounce = fs.readFloat() + self.friction = fs.readFloat() + + self.mode = fs.readSignedByte() + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + if self.bone is None: + fs.writeBoneIndex(-1) + else: + fs.writeBoneIndex(self.bone) + + fs.writeSignedByte(self.collision_group_number) + fs.writeUnsignedShort(self.collision_group_mask) + + fs.writeSignedByte(self.type) + fs.writeVector(self.size) + + fs.writeVector(self.location) + fs.writeVector(self.rotation) + + fs.writeFloat(self.mass) + fs.writeFloat(self.velocity_attenuation) + fs.writeFloat(self.rotation_attenuation) + fs.writeFloat(self.bounce) + fs.writeFloat(self.friction) + + fs.writeSignedByte(self.mode) + +class Joint: + MODE_SPRING6DOF = 0 + def __init__(self): + self.name = '' + self.name_e = '' + + self.mode = 0 + + self.src_rigid = None + self.dest_rigid = None + + self.location = [] + self.rotation = [] + + self.maximum_location = [] + self.minimum_location = [] + self.maximum_rotation = [] + self.minimum_rotation = [] + + self.spring_constant = [] + self.spring_rotation_constant = [] + + def load(self, fs): + try: self._load(fs) + except struct.error: # possibly contains truncated data + if self.src_rigid is None or self.dest_rigid is None: raise + self.location = self.location or (0, 0, 0) + self.rotation = self.rotation or (0, 0, 0) + self.maximum_location = self.maximum_location or (0, 0, 0) + self.minimum_location = self.minimum_location or (0, 0, 0) + self.maximum_rotation = self.maximum_rotation or (0, 0, 0) + self.minimum_rotation = self.minimum_rotation or (0, 0, 0) + self.spring_constant = self.spring_constant or (0, 0, 0) + self.spring_rotation_constant = self.spring_rotation_constant or (0, 0, 0) + + def _load(self, fs): + self.name = fs.readStr() + self.name_e = fs.readStr() + + self.mode = fs.readSignedByte() + + self.src_rigid = fs.readRigidIndex() + self.dest_rigid = fs.readRigidIndex() + if self.src_rigid == -1: + self.src_rigid = None + if self.dest_rigid == -1: + self.dest_rigid = None + + self.location = fs.readVector(3) + self.rotation = fs.readVector(3) + + self.minimum_location = fs.readVector(3) + self.maximum_location = fs.readVector(3) + self.minimum_rotation = fs.readVector(3) + self.maximum_rotation = fs.readVector(3) + + self.spring_constant = fs.readVector(3) + self.spring_rotation_constant = fs.readVector(3) + + def save(self, fs): + fs.writeStr(self.name) + fs.writeStr(self.name_e) + + fs.writeSignedByte(self.mode) + + if self.src_rigid is not None: + fs.writeRigidIndex(self.src_rigid) + else: + fs.writeRigidIndex(-1) + if self.dest_rigid is not None: + fs.writeRigidIndex(self.dest_rigid) + else: + fs.writeRigidIndex(-1) + + fs.writeVector(self.location) + fs.writeVector(self.rotation) + + fs.writeVector(self.minimum_location) + fs.writeVector(self.maximum_location) + fs.writeVector(self.minimum_rotation) + fs.writeVector(self.maximum_rotation) + + fs.writeVector(self.spring_constant) + fs.writeVector(self.spring_rotation_constant) + + + +def load(path): + with FileReadStream(path) as fs: + logging.info('****************************************') + logging.info(' mmd_tools.pmx module') + logging.info('----------------------------------------') + logging.info(' Start to load model data form a pmx file') + logging.info(' by the mmd_tools.pmx modlue.') + logging.info('') + header = Header() + header.load(fs) + fs.setHeader(header) + model = Model() + try: + model.load(fs) + except struct.error as e: + logging.error(' * Corrupted file: %s', e) + #raise + logging.info(' Finished loading.') + logging.info('----------------------------------------') + logging.info(' mmd_tools.pmx module') + logging.info('****************************************') + return model + +def save(path, model, add_uv_count=0): + with FileWriteStream(path) as fs: + header = Header(model) + header.additional_uvs = max(0, min(4, add_uv_count)) # UV1~UV4 + header.save(fs) + fs.setHeader(header) + model.save(fs) diff --git a/core/mmd/core/pmx/importer.py b/core/mmd/core/pmx/importer.py new file mode 100644 index 0000000..d1916a8 --- /dev/null +++ b/core/mmd/core/pmx/importer.py @@ -0,0 +1,1035 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import collections +import logging +import os +import time +from typing import TYPE_CHECKING, List, Optional + +import bpy +from mathutils import Matrix, Vector + +from ... import bpyutils, utils +from ...bpyutils import FnContext +from .. import pmx +from ..bone import FnBone +from ..material import FnMaterial +from ..model import FnModel, Model +from ..morph import FnMorph +from ..rigid_body import FnRigidBody +from ..vmd.importer import BoneConverter +from ...operators.misc import MoveObject + +if TYPE_CHECKING: + from ...properties.pose_bone import MMDBone + from ...properties.root import MMDRoot + + +class PMXImporter: + CATEGORIES = { + 0: "SYSTEM", + 1: "EYEBROW", + 2: "EYE", + 3: "MOUTH", + } + MORPH_TYPES = { + 0: "group_morphs", + 1: "vertex_morphs", + 2: "bone_morphs", + 3: "uv_morphs", + 4: "uv_morphs", + 5: "uv_morphs", + 6: "uv_morphs", + 7: "uv_morphs", + 8: "material_morphs", + } + + def __init__(self): + self.__model = None + self.__targetContext = FnContext.ensure_context() + + self.__scale = None + + self.__root: Optional[bpy.types.Object] = None + self.__armObj: Optional[bpy.types.Object] = None + self.__meshObj: Optional[bpy.types.Object] = None + + self.__vertexGroupTable = None + self.__textureTable = None + self.__rigidTable = None + + self.__boneTable = [] + self.__materialTable = [] + self.__imageTable = {} + + self.__sdefVertices = {} # pmx vertices + self.__blender_ik_links = set() + self.__vertex_map = None + + self.__materialFaceCountTable = None + + @staticmethod + def __safe_name(name, max_length=59): + return str(bytes(name, "utf8")[:max_length], "utf8", errors="replace") + + @staticmethod + def flipUV_V(uv): + u, v = uv + return u, 1.0 - v + + def __createObjects(self): + """Create main objects and link them to scene.""" + 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) + root = self.__rig.rootObject() + mmd_root: MMDRoot = root.mmd_root + self.__root = root + self.__armObj = self.__rig.armature() + + root["import_folder"] = os.path.dirname(pmxModel.filepath) + + txt = bpy.data.texts.new(obj_name) + txt.from_string(pmxModel.comment.replace("\r", "")) + mmd_root.comment_text = txt.name + txt = bpy.data.texts.new(obj_name + "_e") + txt.from_string(pmxModel.comment_e.replace("\r", "")) + mmd_root.comment_e_text = txt.name + + def __createMeshObject(self): + model_name = self.__root.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) + + def __createBasisShapeKey(self): + if self.__meshObj.data.shape_keys: + assert len(self.__meshObj.data.vertices) > 0 + assert len(self.__meshObj.data.shape_keys.key_blocks) > 1 + return + FnContext.set_active_object(self.__targetContext, self.__meshObj) + bpy.ops.object.shape_key_add() + + def __importVertexGroup(self): + vgroups = self.__meshObj.vertex_groups + self.__vertexGroupTable = [vgroups.new(name=i.name) for i in self.__model.bones] or [vgroups.new(name="NO BONES")] + + def __importVertices(self): + self.__importVertexGroup() + + pmxModel = self.__model + pmx_vertices = pmxModel.vertices + vertex_count = len(pmx_vertices) + vertex_map = self.__vertex_map + if vertex_map: + indices = collections.OrderedDict(vertex_map).keys() + pmx_vertices = tuple(pmxModel.vertices[x] for x in indices) + vertex_count = len(indices) + if vertex_count < 1: + return + + mesh: bpy.types.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))) + + vertex_group_table = self.__vertexGroupTable + vg_edge_scale = self.__meshObj.vertex_groups.new(name="mmd_edge_scale") + vg_vertex_order = self.__meshObj.vertex_groups.new(name="mmd_vertex_order") + for i, pv in enumerate(pmx_vertices): + pv_bones, pv_weights, idx = pv.weight.bones, pv.weight.weights, (i,) + + vg_edge_scale.add(index=idx, weight=pv.edge_scale, type="REPLACE") + vg_vertex_order.add(index=idx, weight=i / vertex_count, type="REPLACE") + + if isinstance(pv_weights, pmx.BoneWeightSDEF): + if pv_bones[0] > pv_bones[1]: + pv_bones.reverse() + pv_weights.weight = 1.0 - pv_weights.weight + pv_weights.r0, pv_weights.r1 = pv_weights.r1, pv_weights.r0 + vertex_group_table[pv_bones[0]].add(index=idx, weight=pv_weights.weight, type="ADD") + vertex_group_table[pv_bones[1]].add(index=idx, weight=1.0 - pv_weights.weight, type="ADD") + self.__sdefVertices[i] = pv + elif len(pv_bones) == 1: + bone_index = pv_bones[0] + if bone_index >= 0: + vertex_group_table[bone_index].add(index=idx, weight=1.0, type="ADD") + elif len(pv_bones) == 2: + vertex_group_table[pv_bones[0]].add(index=idx, weight=pv_weights[0], type="ADD") + vertex_group_table[pv_bones[1]].add(index=idx, weight=1.0 - pv_weights[0], type="ADD") + elif len(pv_bones) == 4: + 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.") + + vg_edge_scale.lock_weight = True + vg_vertex_order.lock_weight = True + + def __storeVerticesSDEF(self): + if len(self.__sdefVertices) < 1: + return + + 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)) + + def __importTextures(self): + pmxModel = self.__model + + self.__textureTable = [] + for i in pmxModel.textures: + self.__textureTable.append(bpy.path.resolve_ncase(path=i.path)) + + def __createEditBones(self, obj, pmx_bones): + """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) + + from math import isfinite + + def _VectorXZY(v): + return Vector(v).xzy if all(isfinite(n) for n in v) else Vector((0, 0, 0)) + + with bpyutils.edit_object(obj) as data: + for i in pmx_bones: + bone = data.edit_bones.new(name=i.name) + loc = _VectorXZY(i.location) * self.__scale + bone.head = loc + editBoneTable.append(bone) + nameTable.append(bone.name) + + 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: + b_bone.parent = editBoneTable[m_bone.parent] + else: + b_bone.parent = editBoneTable[m_bone.parent].parent + + for b_bone, m_bone in zip(editBoneTable, pmx_bones): + if isinstance(m_bone.displayConnection, int): + if m_bone.displayConnection != -1: + b_bone.tail = editBoneTable[m_bone.displayConnection].head + else: + b_bone.tail = b_bone.head + else: + loc = _VectorXZY(m_bone.displayConnection) * self.__scale + b_bone.tail = b_bone.head + loc + + 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) + 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] + if self.__fix_IK_links or b_bone_link.length < 0.001: + 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) + elif b_bone_tail.parent != b_bone_link: + logging.warning(" ** skipped IK link %s **", 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) + b_bone_link.tail = b_bone_link.head + loc + + 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 + else: + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + else: + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + 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)) + specialTipBones.append(b_bone.name) + + 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) + + 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] + if t.parent is None or t.parent != b_bone: + continue + if pmx_bones[m_bone.displayConnection].isMovable: + continue + if (b_bone.tail - t.head).length > 1e-4: + continue + if not m_bone.isMovable: + continue + logging.warning(" * connected: %s (%d)-> %s", b_bone.name, len(b_bone.children), t.name) + t.use_connect = True + + return nameTable, specialTipBones + + def __sortPoseBonesByBoneIndex(self, pose_bones: List[bpy.types.PoseBone], bone_names): + r: List[bpy.types.PoseBone] = [] + for i in bone_names: + r.append(pose_bones[i]) + return r + + @staticmethod + def convertIKLimitAngles(min_angle, max_angle, bone_matrix, invert=False): + mat = bone_matrix.to_3x3() * -1 + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + mat.transpose() + if invert: + mat.invert() + + # align matrix to global axes + m = Matrix([[0, 0, 0], [0, 0, 0], [0, 0, 0]]) + i_set, j_set = [0, 1, 2], [0, 1, 2] + for _ in range(3): + ii, jj = i_set[0], j_set[0] + for i in i_set: + for j in j_set: + if abs(mat[i][j]) > abs(mat[ii][jj]): + ii, jj = i, j + i_set.remove(ii) + j_set.remove(jj) + m[ii][jj] = -1 if mat[ii][jj] < 0 else 1 + + new_min_angle = m @ Vector(min_angle) + new_max_angle = m @ Vector(max_angle) + for i in range(3): + if new_min_angle[i] > new_max_angle[i]: + 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 + 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: + # + Root + # | + link1 + # | + link0 (ik_constraint_bone) <- ik constraint, chain_count=2 + # | + IK target (ik_target) <- constraint 'mmd_ik_target_override', subtarget=link0 + # + IK bone (ik_bone) + # + # it is possible that the link0 is the IK target, + # so ik constraint will be on link1, chain_count=1 + # the IK target isn't affected by IK bone + + ik_bone = pose_bones[index] + ik_target = pose_bones[pmx_bone.target] + ik_constraint_bone = ik_target.parent + is_valid_ik = False + 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) + 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) + 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) + return + if ik_constraint_bone is None or len(pmx_bone.ik_links) < 1: + logging.warning(" * Invalid IK bone (%s)", ik_bone.name) + return + + c = ik_target.constraints.new(type="DAMPED_TRACK") + c.name = "mmd_ik_target_override" + c.mute = True + c.influence = 0 + c.target = self.__armObj + c.subtarget = ik_constraint_bone.name + if not is_valid_ik or next((c for c in ik_constraint_bone.constraints if c.type == "IK" and c.is_valid), None): + c.name = "mmd_ik_target_custom" + c.subtarget = ik_bone.name # point to IK control bone + ik_bone.mmd_bone.ik_rotation_constraint = pmx_bone.rotationConstraint + use_custom_ik = True + else: + ik_constraint_bone.mmd_bone.ik_rotation_constraint = pmx_bone.rotationConstraint + use_custom_ik = False + + ikConst = self.__rig.create_ik_constraint(ik_constraint_bone, ik_bone) + ikConst.iterations = pmx_bone.loopCount + ikConst.chain_count = len(pmx_bone.ik_links) + if not is_valid_ik: + ikConst.pole_target = self.__armObj # make it an incomplete/invalid setting + for idx, i in enumerate(pmx_bone.ik_links): + if use_custom_ik or i.target in self.__blender_ik_links: + c = ik_bone.constraints.new(type="LIMIT_ROTATION") + c.mute = True + c.influence = 0 + c.name = "mmd_ik_limit_custom%d" % 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) + c.max_x, c.max_y, c.max_z = maximum + c.min_x, c.min_y, c.min_z = minimum + continue + self.__blender_ik_links.add(i.target) + if i.maximumAngle is not None: + bone = pose_bones[i.target] + minimum, maximum = self.convertIKLimitAngles(i.minimumAngle, i.maximumAngle, bone.bone.matrix_local) + + bone.use_ik_limit_x = True + bone.use_ik_limit_y = True + bone.use_ik_limit_z = True + bone.ik_max_x, bone.ik_max_y, bone.ik_max_z = maximum + bone.ik_min_x, bone.ik_min_y, bone.ik_min_z = minimum + + c = bone.constraints.new(type="LIMIT_ROTATION") + c.mute = not is_valid_ik + c.name = "mmd_ik_limit_override" + c.owner_space = "LOCAL" + c.max_x, c.max_y, c.max_z = maximum + c.min_x, c.min_y, c.min_z = minimum + c.use_limit_x = bone.ik_max_x != c.max_x or bone.ik_min_x != c.min_x + 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): + pmxModel = self.__model + + boneNameTable, specialTipBones = self.__createEditBones(self.__armObj, pmxModel.bones) + pose_bones = self.__sortPoseBonesByBoneIndex(self.__armObj.pose.bones, boneNameTable) + self.__boneTable = pose_bones + 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.name_j = b_bone.name # pmx_bone.name + mmd_bone.name_e = pmx_bone.name_e + mmd_bone.is_controllable = pmx_bone.isControllable + mmd_bone.transform_order = pmx_bone.transform_order + mmd_bone.transform_after_dynamics = pmx_bone.transAfterPhis + + if pmx_bone.displayConnection == -1 or pmx_bone.displayConnection == (0.0, 0.0, 0.0): + mmd_bone.is_tip = True + elif b_bone.name in specialTipBones: + mmd_bone.is_tip = True + + b_bone.bone.hide = not pmx_bone.visible # or mmd_bone.is_tip + + if not pmx_bone.isRotatable: + b_bone.lock_rotation = [True, True, True] + + if not pmx_bone.isMovable: + b_bone.lock_location = [True, True, True] + + if pmx_bone.isIK: + if 0 <= pmx_bone.target < len(pose_bones): + self.__applyIk(i, pmx_bone, pose_bones) + + if pmx_bone.hasAdditionalRotate or pmx_bone.hasAdditionalLocation: + bone_index, influ = pmx_bone.additionalTransform + mmd_bone.has_additional_rotation = pmx_bone.hasAdditionalRotate + mmd_bone.has_additional_location = pmx_bone.hasAdditionalLocation + mmd_bone.additional_transform_influence = influ + if 0 <= bone_index < len(pose_bones): + mmd_bone.additional_transform_bone = pose_bones[bone_index].name + + if pmx_bone.localCoordinate is not None: + mmd_bone.enabled_local_axes = True + mmd_bone.local_axis_x = pmx_bone.localCoordinate.x_axis + mmd_bone.local_axis_z = pmx_bone.localCoordinate.z_axis + + if pmx_bone.axis is not None: + mmd_bone.enabled_fixed_axis = True + mmd_bone.fixed_axis = pmx_bone.axis + + if not self.__apply_bone_fixed_axis and mmd_bone.is_tip: + b_bone.lock_rotation = [True, False, True] + b_bone.lock_location = [True, True, True] + b_bone.lock_scale = [True, True, True] + + def __importRigids(self): + 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)) + for i, (rigid, rigid_obj) in enumerate(zip(self.__model.rigids, rigid_pool)): + loc = Vector(rigid.location).xzy * self.__scale + rot = Vector(rigid.rotation).xzy * -1 + size = Vector(rigid.size).xzy if rigid.type == pmx.Rigid.TYPE_BOX else Vector(rigid.size) + + obj = FnRigidBody.setup_rigid_body_object( + obj=rigid_obj, + shape_type=rigid.type, + location=loc, + rotation=rot, + size=size * self.__scale, + dynamics_type=rigid.mode, + name=rigid.name, + name_e=rigid.name_e, + collision_group_number=rigid.collision_group_number, + collision_group_mask=[rigid.collision_group_mask & (1 << i) == 0 for i in range(16)], + mass=rigid.mass, + friction=rigid.friction, + angular_damping=rigid.rotation_attenuation, + linear_damping=rigid.velocity_attenuation, + bounce=rigid.bounce, + bone=None if rigid.bone == -1 or rigid.bone is None else self.__boneTable[rigid.bone].name, + ) + obj.hide_set(True) + MoveObject.set_index(obj, i) + self.__rigidTable[i] = obj + + logging.debug("Finished importing rigid bodies in %f seconds.", time.time() - start_time) + + def __importJoints(self): + 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())) + for i, (joint, joint_obj) in enumerate(zip(self.__model.joints, joint_pool)): + loc = Vector(joint.location).xzy * self.__scale + rot = Vector(joint.rotation).xzy * -1 + + obj = FnRigidBody.setup_joint_object( + obj=joint_obj, + name=joint.name, + name_e=joint.name_e, + location=loc, + 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_rotation=Vector(joint.minimum_rotation).xzy * -1, + minimum_rotation=Vector(joint.maximum_rotation).xzy * -1, + spring_linear=Vector(joint.spring_constant).xzy, + spring_angular=Vector(joint.spring_rotation_constant).xzy, + ) + obj.hide_set(True) + MoveObject.set_index(obj, i) + + logging.debug("Finished importing joints in %f seconds.", time.time() - start_time) + + def __importMaterials(self): + self.__importTextures() + + pmxModel = self.__model + + self.__materialFaceCountTable = [] + for i in pmxModel.materials: + mat = bpy.data.materials.new(name=self.__safe_name(i.name, max_length=50)) + self.__materialTable.append(mat) + mmd_mat = mat.mmd_material + mmd_mat.name_j = i.name + mmd_mat.name_e = i.name_e + mmd_mat.ambient_color = i.ambient + mmd_mat.diffuse_color = i.diffuse[0:3] + mmd_mat.alpha = i.diffuse[3] + mmd_mat.specular_color = i.specular + mmd_mat.shininess = i.shininess + mmd_mat.is_double_sided = i.is_double_sided + mmd_mat.enabled_drop_shadow = i.enabled_drop_shadow + mmd_mat.enabled_self_shadow_map = i.enabled_self_shadow_map + mmd_mat.enabled_self_shadow = i.enabled_self_shadow + mmd_mat.enabled_toon_edge = i.enabled_toon_edge + mmd_mat.edge_color = i.edge_color + mmd_mat.edge_weight = i.edge_size + mmd_mat.comment = i.comment + + self.__materialFaceCountTable.append(int(i.vertex_count / 3)) + self.__meshObj.data.materials.append(mat) + fnMat = FnMaterial(mat) + if i.texture != -1: + texture_slot = fnMat.create_texture(self.__textureTable[i.texture]) + texture_slot.texture.use_mipmap = self.__use_mipmap + self.__imageTable[len(self.__materialTable) - 1] = texture_slot.texture.image + + if i.is_shared_toon_texture: + mmd_mat.is_shared_toon_texture = True + mmd_mat.shared_toon_texture = i.toon_texture + else: + mmd_mat.is_shared_toon_texture = False + if i.toon_texture >= 0: + mmd_mat.toon_texture = self.__textureTable[i.toon_texture] + + if i.sphere_texture_mode == 2: + amount = self.__spa_blend_factor + else: + amount = self.__sph_blend_factor + if i.sphere_texture != -1 and amount != 0.0: + texture_slot = fnMat.create_sphere_texture(self.__textureTable[i.sphere_texture]) + texture_slot.diffuse_color_factor = amount + 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) + + def __importFaces(self): + pmxModel = self.__model + mesh = self.__meshObj.data + vertex_map = self.__vertex_map + + 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 + material_indices = tuple(i for i, c in enumerate(self.__materialFaceCountTable) for x in range(c)) + + mesh.loops.add(len(pmxModel.faces) * 3) + mesh.loops.foreach_set("vertex_index", loop_indices) + + mesh.polygons.add(len(pmxModel.faces)) + mesh.polygons.foreach_set("loop_start", tuple(range(0, len(mesh.loops), 3))) + mesh.polygons.foreach_set("loop_total", (3,) * len(pmxModel.faces)) + mesh.polygons.foreach_set("use_smooth", (True,) * len(pmxModel.faces)) + mesh.polygons.foreach_set("material_index", material_indices) + + uv_textures, uv_layers = getattr(mesh, "uv_textures", mesh.uv_layers), mesh.uv_layers + uv_tex = uv_textures.new() + uv_layer = uv_layers[uv_tex.name] + uv_table = {vi: self.flipUV_V(v.uv) for vi, v in enumerate(pmxModel.vertices)} + uv_layer.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in uv_table[i])) + + if hasattr(mesh, "uv_textures"): + for bf, mi in zip(uv_tex.data, material_indices): + 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) + 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) + 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) + 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:]) + add_zw = uv_textures.new(name=name) + if add_zw is None: + logging.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) + + 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'. + # 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 = {} + mi_skip = -1 + _vi_cache = {} + + def _rounded_co_vi(vi): + 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)) + return _vi_cache[vi] + + assert len(loop_indices) == len(material_indices) * 3 + for i, mi in enumerate(material_indices): + if mi <= mi_skip: + continue + si = 3 * i + verts = tuple(sorted((_rounded_co_vi(loop_indices[si]), _rounded_co_vi(loop_indices[si + 1]), _rounded_co_vi(loop_indices[si + 2])))) + if verts not in check: + check[verts] = mi + elif check[verts] < mi: + logging.debug(" >> fix blend method of material: %s", materials[mi].name) + materials[mi].blend_method = "BLEND" + materials[mi].show_transparent_back = False + mi_skip = mi + + def __importVertexMorphs(self): + mmd_root = self.__root.mmd_root + categories = self.CATEGORIES + self.__createBasisShapeKey() + for morph in (x for x in self.__model.morphs if isinstance(x, pmx.VertexMorph)): + shapeKey = self.__meshObj.shape_key_add(name=morph.name) + vtx_morph = mmd_root.vertex_morphs.add() + vtx_morph.name = morph.name + vtx_morph.name_e = morph.name_e + vtx_morph.category = categories.get(morph.category, "OTHER") + for md in morph.offsets: + shapeKeyPoint = shapeKey.data[md.index] + shapeKeyPoint.co += Vector(md.offset).xzy * self.__scale + + def __importMaterialMorphs(self): + mmd_root = self.__root.mmd_root + categories = self.CATEGORIES + for morph in (x for x in self.__model.morphs if isinstance(x, pmx.MaterialMorph)): + mat_morph = mmd_root.material_morphs.add() + mat_morph.name = morph.name + mat_morph.name_e = morph.name_e + mat_morph.category = categories.get(morph.category, "OTHER") + for morph_data in morph.offsets: + data = mat_morph.data.add() + data.related_mesh = self.__meshObj.data.name + if 0 <= morph_data.index < len(self.__materialTable): + data.material = self.__materialTable[morph_data.index].name + data.offset_type = ["MULT", "ADD"][morph_data.offset_type] + data.diffuse_color = morph_data.diffuse_offset + data.specular_color = morph_data.specular_offset + data.shininess = morph_data.shininess_offset + data.ambient_color = morph_data.ambient_offset + data.edge_color = morph_data.edge_color_offset + data.edge_weight = morph_data.edge_size_offset + 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 + + def __importBoneMorphs(self): + 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_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") + 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) + data.location = converter.convert_location(morph_data.location_offset) + data.rotation = converter.convert_rotation(morph_data.rotation_offset) + + def __importUVMorphs(self): + 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_morph = mmd_root.uv_morphs.add() + uv_morph.name = morph.name + uv_morph.name_e = morph.name_e + uv_morph.category = categories.get(morph.category, "OTHER") + uv_morph.uv_index = morph.uv_index + + 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" + + def __importGroupMorphs(self): + 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_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") + for morph_data in morph.offsets: + if not (0 <= morph_data.morph < len(pmx_morphs)): + continue + data = group_morph.data.add() + m = pmx_morphs[morph_data.morph] + data.name = m.name + data.morph_type = morph_types[m.type_index()] + data.factor = morph_data.factor + + def __importDisplayFrames(self): + pmxModel = self.__model + root = self.__root + morph_types = self.MORPH_TYPES + + for i in pmxModel.display: + frame = root.mmd_root.display_item_frames.add() + frame.name = i.name + frame.name_e = i.name_e + frame.is_special = i.isSpecial + for disp_type, index in i.data: + item = frame.data.add() + if disp_type == 0: + item.type = "BONE" + item.name = self.__boneTable[index].name + elif disp_type == 1: + item.type = "MORPH" + morph = pmxModel.morphs[index] + item.name = morph.name + item.morph_type = morph_types[morph.type_index()] + else: + raise Exception("Unknown display item type.") + + FnBone.sync_bone_collections_from_display_item_frames(self.__armObj) + + def __addArmatureModifier(self, meshObj, armObj): + # TODO: move to model.py + 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 + + def __assignCustomNormals(self): + mesh: bpy.types.Mesh = self.__meshObj.data + logging.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) + 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!!") + + def __renameLRBones(self, use_underscore): + 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 + + def __translateBoneNames(self): + pose_bones = self.__armObj.pose.bones + for i in pose_bones: + self.__rig.renameBone(i.name, self.__translator.translate(i.name)) + + def __fixRepeatedMorphName(self): + used_names = set() + for m in self.__model.morphs: + m.name = utils.unique_name(m.name or "Morph", used_names) + used_names.add(m.name) + + def execute(self, **args): + if "pmx" in args: + self.__model = args["pmx"] + else: + self.__model = pmx.load(args["filepath"]) + self.__fixRepeatedMorphName() + + types = args.get("types", set()) + clean_model = args.get("clean_model", False) + remove_doubles = args.get("remove_doubles", False) + self.__scale = args.get("scale", 1.0) + self.__use_mipmap = args.get("use_mipmap", True) + self.__sph_blend_factor = args.get("sph_blend_factor", 1.0) + self.__spa_blend_factor = args.get("spa_blend_factor", 1.0) + self.__fix_IK_links = args.get("fix_IK_links", False) + 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() + + self.__createObjects() + + if "MESH" in types: + if clean_model: + _PMXCleaner.clean(self.__model, "MORPHS" not in types) + if remove_doubles: + self.__vertex_map = _PMXCleaner.remove_doubles(self.__model, "MORPHS" not in types) + self.__createMeshObject() + self.__importVertices() + self.__importMaterials() + self.__importFaces() + self.__meshObj.data.update() + self.__assignCustomNormals() + self.__storeVerticesSDEF() + + if "ARMATURE" in types: + # for tracking bone order + if "MESH" not in types: + self.__createMeshObject() + self.__importVertexGroup() + self.__importBones() + if args.get("rename_LR_bones", False): + use_underscore = args.get("use_underscore", False) + self.__renameLRBones(use_underscore) + if self.__translator: + self.__translateBoneNames() + if self.__apply_bone_fixed_axis: + FnBone.apply_bone_fixed_axis(self.__armObj) + FnBone.apply_additional_transformation(self.__armObj) + + if "PHYSICS" in types: + self.__importRigids() + self.__importJoints() + + if "DISPLAY" in types: + self.__importDisplayFrames() + else: + self.__rig.initialDisplayFrames() + + if "MORPHS" in types: + self.__importGroupMorphs() + self.__importVertexMorphs() + self.__importBoneMorphs() + self.__importMaterialMorphs() + self.__importUVMorphs() + + if self.__meshObj: + self.__addArmatureModifier(self.__meshObj, self.__armObj) + + 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("****************************************") + + +class _PMXCleaner: + @classmethod + def clean(cls, pmx_model, mesh_only): + logging.info("Cleaning PMX data...") + pmx_faces = pmx_model.faces + pmx_vertices = pmx_model.vertices + + # clean face/vertex + cls.__clean_pmx_faces(pmx_faces, pmx_model.materials, lambda f: frozenset(f)) + + 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)") + else: + new_vertex_count = 0 + for v in sorted(index_map): + if v != new_vertex_count: + 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) + del pmx_vertices[new_vertex_count:] + + # update vertex indices of faces + for f in pmx_faces: + f[:] = [index_map[v] for v in f] + + if mesh_only: + logging.info(" - Done (mesh only)!!") + return + + if not is_index_clean: + # clean vertex/uv morphs + def __update_index(x): + 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!!") + + @classmethod + def remove_doubles(cls, pmx_model, mesh_only): + logging.info("Removing doubles...") + pmx_vertices = pmx_model.vertices + + vertex_map = [None] * len(pmx_vertices) + # gather vertex data + for i, v in enumerate(pmx_vertices): + vertex_map[i] = [tuple(v.co)] + if not mesh_only: + for i, m in enumerate(pmx_model.morphs): + if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): + continue + for x in m.offsets: + vertex_map[x.index].append((i,) + tuple(x.offset)) + # generate vertex merging table + keys = {} + for i, v in enumerate(vertex_map): + k = tuple(v) + if k in keys: + vertex_map[i] = keys[k] # merge pmx_vertices[i] to pmx_vertices[keys[k][0]] + else: + vertex_map[i] = keys[k] = (i, len(keys)) # (pmx index, blender index) + counts = len(vertex_map) - len(keys) + keys.clear() + if counts: + logging.warning(" - %d vertices will be removed", counts) + else: + logging.info(" - Done (no changes)!!") + return None + + # clean face + # face_key_func = lambda f: frozenset(vertex_map[x][0] for x in f) + face_key_func = lambda f: frozenset({vertex_map[x][0]: tuple(pmx_vertices[x].uv) for x in f}.items()) + cls.__clean_pmx_faces(pmx_model.faces, pmx_model.materials, face_key_func) + + if mesh_only: + logging.info(" - Done (mesh only)!!") + else: + # clean vertex/uv morphs + def __update_index(x): + 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!!") + return vertex_map + + @staticmethod + def __clean_pmx_faces(pmx_faces, pmx_materials, face_key_func): + new_face_count = 0 + face_iter = iter(pmx_faces) + for mat in pmx_materials: + used_faces = set() + new_vertex_count = 0 + for i in range(int(mat.vertex_count / 3)): + f = next(face_iter) + + f_key = face_key_func(f) + if len(f_key) != 3 or f_key in used_faces: + continue + used_faces.add(f_key) + + pmx_faces[new_face_count] = list(f) + new_face_count += 1 + new_vertex_count += 3 + mat.vertex_count = new_vertex_count + face_iter = None + if new_face_count == len(pmx_faces): + logging.info(" (faces is clean)") + else: + logging.warning(" - removed %d faces", len(pmx_faces) - new_face_count) + del pmx_faces[new_face_count:] + + @staticmethod + def __clean_pmx_morphs(pmx_morphs, index_update_func): + for m in pmx_morphs: + if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): + continue + old_len = len(m.offsets) + 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) diff --git a/core/mmd/core/rigid_body.py b/core/mmd/core/rigid_body.py new file mode 100644 index 0000000..ec3aeb8 --- /dev/null +++ b/core/mmd/core/rigid_body.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import List, Optional + +import bpy +from mathutils import Euler, Vector + +from ..bpyutils import FnContext, Props + +SHAPE_SPHERE = 0 +SHAPE_BOX = 1 +SHAPE_CAPSULE = 2 + +MODE_STATIC = 0 +MODE_DYNAMIC = 1 +MODE_DYNAMIC_BONE = 2 + + +def shapeType(collision_shape): + return ("SPHERE", "BOX", "CAPSULE").index(collision_shape) + + +def collisionShape(shape_type): + return ("SPHERE", "BOX", "CAPSULE")[shape_type] + + +def setRigidBodyWorldEnabled(enable): + if bpy.ops.rigidbody.world_add.poll(): + bpy.ops.rigidbody.world_add() + rigidbody_world = bpy.context.scene.rigidbody_world + enabled = rigidbody_world.enabled + rigidbody_world.enabled = enable + return enabled + + +class RigidBodyMaterial: + COLORS = [ + 0x7FDDD4, + 0xF0E68C, + 0xEE82EE, + 0xFFE4E1, + 0x8FEEEE, + 0xADFF2F, + 0xFA8072, + 0x9370DB, + 0x40E0D0, + 0x96514D, + 0x5A964E, + 0xE6BFAB, + 0xD3381C, + 0x165E83, + 0x701682, + 0x828216, + ] + + @classmethod + def getMaterial(cls, number): + number = int(number) + material_name = "mmd_tools_rigid_%d" % (number) + if material_name not in bpy.data.materials: + 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)] + mat.specular_intensity = 0 + if len(mat.diffuse_color) > 3: + mat.diffuse_color[3] = 0.5 + mat.blend_method = "BLEND" + if hasattr(mat, "shadow_method"): + mat.shadow_method = "NONE" + mat.use_backface_culling = True + mat.show_transparent_back = False + mat.use_nodes = True + nodes, links = mat.node_tree.nodes, mat.node_tree.links + nodes.clear() + node_color = nodes.new("ShaderNodeBackground") + node_color.inputs["Color"].default_value = mat.diffuse_color + node_output = nodes.new("ShaderNodeOutputMaterial") + links.new(node_color.outputs[0], node_output.inputs["Surface"]) + else: + mat = bpy.data.materials[material_name] + return mat + + +class FnRigidBody: + @staticmethod + def new_rigid_body_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int) -> List[bpy.types.Object]: + if count < 1: + return [] + + obj = FnRigidBody.new_rigid_body_object(context, parent_object) + + if count == 1: + return [obj] + + return FnContext.duplicate_object(context, obj, count) + + @staticmethod + def new_rigid_body_object(context: bpy.types.Context, parent_object: bpy.types.Object) -> bpy.types.Object: + 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" + obj.rotation_mode = "YXZ" + setattr(obj, Props.display_type, "SOLID") + obj.show_transparent = True + obj.hide_render = True + obj.display.show_shadows = False + + with context.temp_override(object=obj): + bpy.ops.rigidbody.object_add(type="ACTIVE") + + return obj + + @staticmethod + def setup_rigid_body_object( + obj: bpy.types.Object, + shape_type: str, + location: Vector, + rotation: Euler, + size: Vector, + dynamics_type: str, + collision_group_number: Optional[int] = None, + collision_group_mask: Optional[List[bool]] = None, + name: Optional[str] = None, + name_e: Optional[str] = None, + bone: Optional[str] = None, + friction: Optional[float] = None, + mass: Optional[float] = None, + angular_damping: Optional[float] = None, + linear_damping: Optional[float] = None, + bounce: Optional[float] = None, + ) -> bpy.types.Object: + obj.location = location + obj.rotation_euler = rotation + + obj.mmd_rigid.shape = collisionShape(shape_type) + obj.mmd_rigid.size = size + obj.mmd_rigid.type = str(dynamics_type) if dynamics_type in range(3) else "1" + + if collision_group_number is not None: + obj.mmd_rigid.collision_group_number = collision_group_number + + if collision_group_mask is not None: + obj.mmd_rigid.collision_group_mask = collision_group_mask + + if name is not None: + obj.name = name + obj.mmd_rigid.name_j = name + obj.data.name = name + + if name_e is not None: + obj.mmd_rigid.name_e = name_e + + if bone is not None: + obj.mmd_rigid.bone = bone + else: + obj.mmd_rigid.bone = "" + + rb = obj.rigid_body + if friction is not None: + rb.friction = friction + if mass is not None: + rb.mass = mass + if angular_damping is not None: + rb.angular_damping = angular_damping + if linear_damping is not None: + rb.linear_damping = linear_damping + if bounce is not None: + rb.restitution = bounce + + return obj + + @staticmethod + def get_rigid_body_size(obj: bpy.types.Object): + assert obj.mmd_type == "RIGID_BODY" + + x0, y0, z0 = obj.bound_box[0] + x1, y1, z1 = obj.bound_box[6] + assert x1 >= x0 and y1 >= y0 and z1 >= z0 + + shape = obj.mmd_rigid.shape + if shape == "SPHERE": + radius = (z1 - z0) / 2 + return (radius, 0.0, 0.0) + elif shape == "BOX": + x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2 + return (x, y, z) + elif shape == "CAPSULE": + diameter = x1 - x0 + radius = diameter / 2 + height = abs((z1 - z0) - diameter) + return (radius, height, 0.0) + else: + raise ValueError(f"Invalid shape type: {shape}") + + @staticmethod + def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object: + obj = FnContext.new_and_link_object(context, name="Joint", object_data=None) + obj.parent = parent_object + obj.mmd_type = "JOINT" + obj.rotation_mode = "YXZ" + setattr(obj, Props.empty_display_type, "ARROWS") + setattr(obj, Props.empty_display_size, 0.1 * empty_display_size) + obj.hide_render = True + + with context.temp_override(): + context.view_layer.objects.active = obj + bpy.ops.rigidbody.constraint_add(type="GENERIC_SPRING") + + rigid_body_constraint = obj.rigid_body_constraint + rigid_body_constraint.disable_collisions = False + rigid_body_constraint.use_limit_ang_x = True + rigid_body_constraint.use_limit_ang_y = True + rigid_body_constraint.use_limit_ang_z = True + rigid_body_constraint.use_limit_lin_x = True + rigid_body_constraint.use_limit_lin_y = True + rigid_body_constraint.use_limit_lin_z = True + rigid_body_constraint.use_spring_x = True + rigid_body_constraint.use_spring_y = True + rigid_body_constraint.use_spring_z = True + rigid_body_constraint.use_spring_ang_x = True + rigid_body_constraint.use_spring_ang_y = True + rigid_body_constraint.use_spring_ang_z = True + + return obj + + @staticmethod + def new_joint_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int, empty_display_size: float) -> List[bpy.types.Object]: + if count < 1: + return [] + + obj = FnRigidBody.new_joint_object(context, parent_object, empty_display_size) + + if count == 1: + return [obj] + + return FnContext.duplicate_object(context, obj, count) + + @staticmethod + def setup_joint_object( + obj: bpy.types.Object, + location: Vector, + rotation: Euler, + rigid_a: bpy.types.Object, + rigid_b: bpy.types.Object, + maximum_location: Vector, + minimum_location: Vector, + maximum_rotation: Euler, + minimum_rotation: Euler, + spring_angular: Vector, + spring_linear: Vector, + name: str, + name_e: Optional[str] = None, + ) -> bpy.types.Object: + obj.name = f"J.{name}" + + obj.location = location + obj.rotation_euler = rotation + + rigid_body_constraint = obj.rigid_body_constraint + rigid_body_constraint.object1 = rigid_a + rigid_body_constraint.object2 = rigid_b + rigid_body_constraint.limit_lin_x_upper = maximum_location.x + rigid_body_constraint.limit_lin_y_upper = maximum_location.y + rigid_body_constraint.limit_lin_z_upper = maximum_location.z + + rigid_body_constraint.limit_lin_x_lower = minimum_location.x + rigid_body_constraint.limit_lin_y_lower = minimum_location.y + rigid_body_constraint.limit_lin_z_lower = minimum_location.z + + rigid_body_constraint.limit_ang_x_upper = maximum_rotation.x + rigid_body_constraint.limit_ang_y_upper = maximum_rotation.y + rigid_body_constraint.limit_ang_z_upper = maximum_rotation.z + + rigid_body_constraint.limit_ang_x_lower = minimum_rotation.x + rigid_body_constraint.limit_ang_y_lower = minimum_rotation.y + rigid_body_constraint.limit_ang_z_lower = minimum_rotation.z + + obj.mmd_joint.name_j = name + if name_e is not None: + obj.mmd_joint.name_e = name_e + + obj.mmd_joint.spring_linear = spring_linear + obj.mmd_joint.spring_angular = spring_angular + + return obj diff --git a/core/mmd/core/sdef.py b/core/mmd/core/sdef.py new file mode 100644 index 0000000..4e4f768 --- /dev/null +++ b/core/mmd/core/sdef.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import time + +import bpy +from mathutils import Matrix, Vector + +from ..bpyutils import FnObject + + +def _hash(v): + if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)): + return hash(type(v).__name__ + v.name) + elif isinstance(v, bpy.types.Pose): + return hash(type(v).__name__ + v.id_data.name) + else: + raise NotImplementedError("hash") + + +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" + + def __init__(self): + raise NotImplementedError("not allowed") + + @classmethod + def __init_cache(cls, obj, shapekey): + 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: + cls.g_verts[key] = cls.__find_vertices(obj) + cls.g_bone_check[key] = {} + cls.__g_armature_check[key] = key_armature + cls.g_shapekey_data[key] = None + return True + return False + + @classmethod + def __check_bone_update(cls, obj, bone0, bone1): + check = cls.g_bone_check[_hash(obj)] + key = (_hash(bone0), _hash(bone1)) + if key not in check or (bone0.matrix, bone1.matrix) != check[key]: + check[key] = (bone0.matrix.copy(), bone1.matrix.copy()) + return True + return False + + @classmethod + def mute_sdef_set(cls, obj, mute): + key_blocks = getattr(obj.data.shape_keys, "key_blocks", ()) + if cls.SHAPEKEY_NAME in key_blocks: + shapekey = key_blocks[cls.SHAPEKEY_NAME] + shapekey.mute = mute + if cls.has_sdef_data(obj): + cls.__init_cache(obj, shapekey) + cls.__sdef_muted(obj, shapekey) + + @classmethod + def __sdef_muted(cls, obj, shapekey): + mute = shapekey.mute + if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"): + mod = obj.modifiers.get("mmd_bone_order_override") + if mod and mod.type == "ARMATURE": + if not mute and cls.MASK_NAME not in obj.vertex_groups and obj.mode != "EDIT": + mask = tuple(i for v in cls.g_verts[_hash(obj)].values() for i in v[3]) + obj.vertex_groups.new(name=cls.MASK_NAME).add(mask, 1, "REPLACE") + mod.vertex_group = "" if mute else cls.MASK_NAME + mod.invert_vertex_group = True + shapekey.vertex_group = cls.MASK_NAME + cls.g_bone_check[_hash(obj)]["sdef_mute"] = mute + return mute + + @staticmethod + def has_sdef_data(obj): + 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) + return kb and "mmd_sdef_c" in kb and "mmd_sdef_r0" in kb and "mmd_sdef_r1" in kb + return False + + @classmethod + def __find_vertices(cls, obj): + if not cls.has_sdef_data(obj): + return {} + + vertices = {} + 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} + 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 + + 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 + if len(bgs) >= 2: + bgs.sort(key=lambda x: x.group) + # preprocessing + w0, w1 = bgs[0].weight, bgs[1].weight + # w0 + w1 == 1 + w0 = w0 / (w0 + w1) + w1 = 1 - w0 + + c, r0, r1 = sdef_c[i].co, sdef_r0[i].co, sdef_r1[i].co + rw = r0 * w0 + r1 * w1 + r0 = c + r0 - rw + r1 = c + r1 - rw + + key = (bgs[0].group, bgs[1].group) + if key not in vertices: + # TODO basically we can not cache any bone reference + 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) + return vertices + + @classmethod + def driver_function_wrap(cls, obj_name, bulk_update, use_skip, use_scale): + 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): + 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 + # cls.driver_function(shapekey.id_data.original.key_blocks[shapekey.name], obj_name, bulk_update, use_skip, use_scale) # update original data + data_path = shapekey.path_from_id("value") + obj = next(i for i in shapekey.id_data.animation_data.drivers if i.data_path == data_path).driver.variables["obj"].targets[0].id + cls.__init_cache(obj, shapekey) + if cls.__sdef_muted(obj, shapekey): + return 0.0 + + pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones + if not bulk_update: + shapekey_data = shapekey.data + if use_scale: + # with scale + key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME) + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + # if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + # continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + s0, s1 = mat0.to_scale(), mat1.to_scale() + for vid, w0, w1, pos_c, cr0, cr1 in sdef_data: + s = s0 * w0 + s1 * w1 + mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix() @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])]) + delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = '' + shapekey_data[vid].co = (mat_rot @ (pos_c + delta)) - delta + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 + else: + # default + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + # workaround some weird result of matrix.to_quaternion() using to_euler(), but still minor issues + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + for vid, w0, w1, pos_c, cr0, cr1 in sdef_data: + mat_rot = (rot0 * w0 + rot1 * w1).normalized().to_matrix() + shapekey_data[vid].co = (mat_rot @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 + else: # bulk update + shapekey_data = cls.g_shapekey_data[_hash(obj)] + if shapekey_data is None: + import numpy as np + + shapekey_data = np.zeros(len(shapekey.data) * 3, dtype=np.float32) + shapekey.data.foreach_get("co", shapekey_data) + shapekey_data = cls.g_shapekey_data[_hash(obj)] = shapekey_data.reshape(len(shapekey.data), 3) + if use_scale: + # scale & bulk update + key_blocks = tuple(k for k in shapekey.id_data.key_blocks[1:] if not k.mute and k.value and k.name != cls.SHAPEKEY_NAME) + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + # if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + # continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + s0, s1 = mat0.to_scale(), mat1.to_scale() + + def scale(mat_rot, w0, w1): + 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): + delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = '' + return (mat_rot @ (pos_c + delta)) - delta + + shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] + else: + # bulk update + for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): + bone0, bone1 = pose_bones[bone0.name], pose_bones[bone1.name] + if use_skip and not cls.__check_bone_update(obj, bone0, bone1): + continue + mat0 = bone0.matrix @ bone0.bone.matrix_local.inverted() + mat1 = bone1.matrix @ bone1.bone.matrix_local.inverted() + rot0 = mat0.to_euler("YXZ").to_quaternion() + rot1 = mat1.to_euler("YXZ").to_quaternion() + if rot1.dot(rot0) < 0: + rot1 = -rot1 + shapekey_data[vids] = [((rot0 * w0 + rot1 * w1).normalized().to_matrix() @ pos_c) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] + shapekey.data.foreach_set("co", shapekey_data.reshape(3 * len(shapekey.data))) + + return 1.0 # shapkey value + + @classmethod + def register_driver_function(cls): + if "mmd_sdef_driver" not in bpy.app.driver_namespace: + bpy.app.driver_namespace["mmd_sdef_driver"] = cls.driver_function + if "mmd_sdef_driver_wrap" not in bpy.app.driver_namespace: + bpy.app.driver_namespace["mmd_sdef_driver_wrap"] = cls.driver_function_wrap + + BENCH_LOOP = 10 + + @classmethod + def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip): + # warmed up + cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale) + cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale) + # benchmark + t = time.time() + for i in range(cls.BENCH_LOOP): + cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale) + default_time = time.time() - t + t = time.time() + for i in range(cls.BENCH_LOOP): + 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) + return result + + @classmethod + def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False): + # Unbind first + cls.unbind(obj) + if not cls.has_sdef_data(obj): + return False + # Create the shapekey for the driver + shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False) + cls.__init_cache(obj, shapekey) + cls.__sdef_muted(obj, shapekey) + cls.register_driver_function() + if bulk_update is None: + bulk_update = cls.__get_benchmark_result(obj, shapekey, use_scale, use_skip) + # Add the driver to the shapekey + f = obj.data.shape_keys.driver_add('key_blocks["' + cls.SHAPEKEY_NAME + '"].value', -1) + if hasattr(f.driver, "show_debug_info"): + f.driver.show_debug_info = False + f.driver.type = "SCRIPTED" + ov = f.driver.variables.new() + ov.name = "obj" + ov.type = "SINGLE_PROP" + ov.targets[0].id = obj + ov.targets[0].data_path = "name" + if not bulk_update and use_skip: # FIXME: force disable use_skip=True for bulk_update=False on 2.8 + use_skip = False + mod = obj.modifiers.get("mmd_bone_order_override") + variables = f.driver.variables + for name in set(data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)): # add required bones for dependency graph + var = variables.new() + var.type = "TRANSFORMS" + var.targets[0].id = mod.object + var.targets[0].bone_target = name + f.driver.use_self = True + param = (bulk_update, use_skip, use_scale) + f.driver.expression = "mmd_sdef_driver(self, obj, bulk_update={}, use_skip={}, use_scale={})".format(*param) + return True + + @classmethod + def unbind(cls, obj): + if obj.data.shape_keys: + if cls.SHAPEKEY_NAME in obj.data.shape_keys.key_blocks: + 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: + mod.vertex_group = "" + mod.invert_vertex_group = False + break + if cls.MASK_NAME in obj.vertex_groups: + obj.vertex_groups.remove(obj.vertex_groups[cls.MASK_NAME]) + cls.clear_cache(obj) + + @classmethod + def clear_cache(cls, obj=None, unused_only=False): + if unused_only: + valid_keys = set(_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj) + for key in cls.g_verts.keys() - valid_keys: + del cls.g_verts[key] + for key in cls.g_shapekey_data.keys() - cls.g_verts.keys(): + del cls.g_shapekey_data[key] + for key in cls.g_bone_check.keys() - cls.g_verts.keys(): + del cls.g_bone_check[key] + elif obj: + key = _hash(obj) + if key in cls.g_verts: + del cls.g_verts[key] + if key in cls.g_shapekey_data: + del cls.g_shapekey_data[key] + if key in cls.g_bone_check: + del cls.g_bone_check[key] + else: + 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 new file mode 100644 index 0000000..9d32742 --- /dev/null +++ b/core/mmd/core/shader.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Optional, Tuple, cast +import bpy + + +class _NodeTreeUtils: + def __init__(self, shader: bpy.types.ShaderNodeTree): + self.shader = shader + self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore + self.links = shader.links + + def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]: + return next((n for n in self.nodes if n.bl_idname == node_type), None) + + def new_node(self, idname: str, pos: Tuple[int, int]) -> bpy.types.ShaderNode: + node: bpy.types.ShaderNode = self.nodes.new(idname) + node.location = (pos[0] * 210, pos[1] * 220) + return node + + def new_math_node(self, operation, pos, value1=None, value2=None): + node = self.new_node("ShaderNodeMath", pos) + node.operation = operation + if value1 is not None: + node.inputs[0].default_value = value1 + if value2 is not None: + node.inputs[1].default_value = value2 + return node + + def new_vector_math_node(self, operation, pos, vector1=None, vector2=None): + node = self.new_node("ShaderNodeVectorMath", pos) + node.operation = operation + if vector1 is not None: + node.inputs[0].default_value = vector1 + if vector2 is not None: + node.inputs[1].default_value = vector2 + return node + + def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None): + node = self.new_node("ShaderNodeMixRGB", pos) + node.blend_type = blend_type + if fac is not None: + node.inputs["Fac"].default_value = fac + if color1 is not None: + node.inputs["Color1"].default_value = color1 + if color2 is not None: + node.inputs["Color2"].default_value = color2 + return node + + +SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"} + +SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"} + + +class _NodeGroupUtils(_NodeTreeUtils): + def __init__(self, shader: bpy.types.ShaderNodeTree): + super().__init__(shader) + self.__node_input: Optional[bpy.types.NodeGroupInput] = None + self.__node_output: Optional[bpy.types.NodeGroupOutput] = None + + @property + def node_input(self) -> bpy.types.NodeGroupInput: + if not self.__node_input: + self.__node_input = cast(bpy.types.NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) + return self.__node_input + + @property + def node_output(self) -> bpy.types.NodeGroupOutput: + if not self.__node_output: + self.__node_output = cast(bpy.types.NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) + return self.__node_output + + def hide_nodes(self, hide_sockets=True): + skip_nodes = {self.__node_input, self.__node_output} + for n in (x for x in self.nodes if x not in skip_nodes): + n.hide = True + if not hide_sockets: + continue + for s in n.inputs: + s.hide = not s.is_linked + 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): + 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): + 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): + if io_name not in io_sockets: + idname = socket_type or socket.bl_idname + interface_socket = self.shader.interface.new_socket(name=io_name, in_out=in_out, socket_type=SOCKET_TYPE_MAPPING.get(idname, idname)) + if idname in SOCKET_SUBTYPE_MAPPING: + interface_socket.subtype = SOCKET_SUBTYPE_MAPPING.get(idname, "") + if not min_max: + if idname.endswith("Factor") or io_name.endswith("Alpha"): + interface_socket.min_value, interface_socket.max_value = 0, 1 + elif idname.endswith("Float") or idname.endswith("Vector"): + interface_socket.min_value, interface_socket.max_value = -10, 10 + if socket is not None: + self.links.new(io_sockets[io_name], socket) + if default_val is not None: + interface_socket.default_value = default_val + if min_max is not None: + interface_socket.min_value, interface_socket.max_value = min_max + + +class _MaterialMorph: + @classmethod + def update_morph_inputs(cls, material, morph): + if material and material.node_tree and morph.name in material.node_tree.nodes: + 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): + node, nodes = None, [] + for m in morphs: + node = cls.__morph_node_add(material, m, node) + nodes.append(node) + if node: + node = cls.__morph_node_add(material, None, node) or node + for n in reversed(nodes): + n.location += node.location + if n.node_tree.name != node.node_tree.name: + n.location.x -= 100 + if node.name.startswith("mmd_"): + n.location.y += 1500 + node = n + return nodes + + @classmethod + def reset_morph_links(cls, node): + cls.__update_morph_links(node, reset=True) + + @classmethod + def __update_morph_links(cls, node, reset=False): + nodes, links = node.id_data.nodes, node.id_data.links + if reset: + if any(l.from_node.name.startswith("mmd_bind") for i in node.inputs for l in i.links): + return + + def __init_link(socket_morph, socket_shader): + if socket_shader and socket_morph.is_linked: + links.new(socket_morph.links[0].from_socket, socket_shader) + + else: + + def __init_link(socket_morph, socket_shader): + if socket_shader: + if socket_shader.is_linked: + links.new(socket_shader.links[0].from_socket, socket_morph) + if socket_morph.type == "VALUE": + socket_morph.default_value = socket_shader.default_value + else: + socket_morph.default_value[:3] = socket_shader.default_value[:3] + + shader = nodes.get("mmd_shader", None) + if shader: + __init_link(node.inputs["Ambient1"], shader.inputs.get("Ambient Color")) + __init_link(node.inputs["Diffuse1"], shader.inputs.get("Diffuse Color")) + __init_link(node.inputs["Specular1"], shader.inputs.get("Specular Color")) + __init_link(node.inputs["Reflect1"], shader.inputs.get("Reflect")) + __init_link(node.inputs["Alpha1"], shader.inputs.get("Alpha")) + __init_link(node.inputs["Base1 RGB"], shader.inputs.get("Base Tex")) + __init_link(node.inputs["Toon1 RGB"], shader.inputs.get("Toon Tex")) # FIXME toon only affect shadow color + __init_link(node.inputs["Sphere1 RGB"], shader.inputs.get("Sphere Tex")) + elif "mmd_edge_preview" in nodes: + shader = nodes["mmd_edge_preview"] + __init_link(node.inputs["Edge1 RGB"], shader.inputs["Color"]) + __init_link(node.inputs["Edge1 A"], shader.inputs["Alpha"]) + + @classmethod + def __update_node_inputs(cls, node, morph): + node.inputs["Ambient2"].default_value[:3] = morph.ambient_color[:3] + node.inputs["Diffuse2"].default_value[:3] = morph.diffuse_color[:3] + node.inputs["Specular2"].default_value[:3] = morph.specular_color[:3] + node.inputs["Reflect2"].default_value = morph.shininess + node.inputs["Alpha2"].default_value = morph.diffuse_color[3] + + node.inputs["Edge2 RGB"].default_value[:3] = morph.edge_color[:3] + node.inputs["Edge2 A"].default_value = morph.edge_color[3] + + node.inputs["Base2 RGB"].default_value[:3] = morph.texture_factor[:3] + node.inputs["Base2 A"].default_value = morph.texture_factor[3] + node.inputs["Toon2 RGB"].default_value[:3] = morph.toon_texture_factor[:3] + node.inputs["Toon2 A"].default_value = morph.toon_texture_factor[3] + node.inputs["Sphere2 RGB"].default_value[:3] = morph.sphere_texture_factor[:3] + node.inputs["Sphere2 A"].default_value = morph.sphere_texture_factor[3] + + @classmethod + def __morph_node_add(cls, material, morph, prev_node): + nodes, links = material.node_tree.nodes, material.node_tree.links + + shader = nodes.get("mmd_shader", None) + if morph: + node = nodes.new("ShaderNodeGroup") + node.parent = getattr(shader, "parent", None) + node.location = (-250, 0) + node.node_tree = cls.__get_shader("Add" if morph.offset_type == "ADD" else "Mul") + cls.__update_node_inputs(node, morph) + if prev_node: + for id_name in ("Ambient", "Diffuse", "Specular", "Reflect", "Alpha"): + links.new(prev_node.outputs[id_name], node.inputs[id_name + "1"]) + for id_name in ("Edge", "Base", "Toon", "Sphere"): + links.new(prev_node.outputs[id_name + " RGB"], node.inputs[id_name + "1 RGB"]) + links.new(prev_node.outputs[id_name + " A"], node.inputs[id_name + "1 A"]) + else: # initial first node + if node.node_tree.name.endswith("Add"): + node.inputs["Base1 A"].default_value = 1 + node.inputs["Toon1 A"].default_value = 1 + node.inputs["Sphere1 A"].default_value = 1 + cls.__update_morph_links(node) + return node + # connect last node to shader + if shader: + + def __soft_link(socket_out, socket_in): + if socket_out and socket_in: + links.new(socket_out, socket_in) + + __soft_link(prev_node.outputs["Ambient"], shader.inputs.get("Ambient Color")) + __soft_link(prev_node.outputs["Diffuse"], shader.inputs.get("Diffuse Color")) + __soft_link(prev_node.outputs["Specular"], shader.inputs.get("Specular Color")) + __soft_link(prev_node.outputs["Reflect"], shader.inputs.get("Reflect")) + __soft_link(prev_node.outputs["Alpha"], shader.inputs.get("Alpha")) + __soft_link(prev_node.outputs["Base Tex"], shader.inputs.get("Base Tex")) + __soft_link(prev_node.outputs["Toon Tex"], shader.inputs.get("Toon Tex")) + if int(material.mmd_material.sphere_texture_type) != 2: # shader.inputs['Sphere Mul/Add'].default_value < 0.5 + __soft_link(prev_node.outputs["Sphere Tex"], shader.inputs.get("Sphere Tex")) + else: + __soft_link(prev_node.outputs["Sphere Tex Add"], shader.inputs.get("Sphere Tex")) + elif "mmd_edge_preview" in nodes: + shader = nodes["mmd_edge_preview"] + links.new(prev_node.outputs["Edge RGB"], shader.inputs["Color"]) + links.new(prev_node.outputs["Edge A"], shader.inputs["Alpha"]) + return shader + + @classmethod + def __get_shader(cls, morph_type): + group_name = "MMDMorph" + morph_type + shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") + if len(shader.nodes): + return shader + + ng = _NodeGroupUtils(shader) + links = ng.links + + use_mul = morph_type == "Mul" + + ############################################################################ + node_input = ng.new_node("NodeGroupInput", (-3, 0)) + ng.new_input_socket("Fac", None, 0, socket_type="NodeSocketFloat") + ng.new_node("NodeGroupOutput", (3, 0)) + + def __blend_color_add(id_name, pos, tag=""): + # MA_RAMP_MULT: ColorMul = Color1 * (Fac * Color2 + (1 - Fac)) + # MA_RAMP_ADD: ColorAdd = Color1 + Fac * Color2 + # https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/source/blender/blenkernel/intern/material.c#L1400 + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos[0] + 1, pos[1])) + links.new(node_input.outputs["Fac"], node_mix.inputs["Fac"]) + ng.new_input_socket("%s1" % id_name + tag, node_mix.inputs["Color1"]) + ng.new_input_socket("%s2" % id_name + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector") + ng.new_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): + # Tex Color = tex_rgb * tex_a + (1 - tex_a) + # : tex_rgb = TexRGB * ColorMul + ColorAdd + # : tex_a = TexA * ValueMul + ValueAdd + if id_name != "Sphere": + node_mix = ng.new_mix_node("MULTIPLY", pos, color1=(1, 1, 1, 1)) + links.new(node_tex_a_output, node_mix.inputs[0]) + links.new(node_tex_rgb.outputs["Color"], node_mix.inputs[2]) + ng.new_output_socket(id_name + " Tex", node_mix.outputs[0]) + else: + node_inv = ng.new_math_node("SUBTRACT", (pos[0], pos[1] - 0.25), value1=1.0) + node_scale = ng.new_vector_math_node("SCALE", (pos[0], pos[1])) + node_add = ng.new_vector_math_node("ADD", (pos[0] + 1, pos[1])) + + links.new(node_tex_a_output, node_inv.inputs[1]) + links.new(node_tex_rgb.outputs["Color"], node_scale.inputs[0]) + links.new(node_tex_a_output, node_scale.inputs["Scale"]) + links.new(node_scale.outputs[0], node_add.inputs[0]) + links.new(node_inv.outputs[0], node_add.inputs[1]) + + 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=""): + 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) + + pos_x = -2 + __blend_color_add("Ambient", (pos_x, +0.5)) + __blend_color_add("Diffuse", (pos_x, +0.0)) + __blend_color_add("Specular", (pos_x, -0.5)) + + combine_reflect1_alpha1_edge1 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.5)) + combine_reflect2_alpha2_edge2 = ng.new_node("ShaderNodeCombineRGB", (-2, -1.75)) + separate_reflect_alpha_edge = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -1.5)) + + __add_sockets("Reflect", combine_reflect1_alpha1_edge1.inputs[0], combine_reflect2_alpha2_edge2.inputs[0], separate_reflect_alpha_edge.outputs[0]) + __add_sockets("Alpha", combine_reflect1_alpha1_edge1.inputs[1], combine_reflect2_alpha2_edge2.inputs[1], separate_reflect_alpha_edge.outputs[1]) + + __blend_color_add("Edge", (pos_x, -1.0), " RGB") + __add_sockets("Edge", combine_reflect1_alpha1_edge1.inputs[2], combine_reflect2_alpha2_edge2.inputs[2], separate_reflect_alpha_edge.outputs[2], tag=" A") + + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -1.5)) + links.new(node_input.outputs["Fac"], node_mix.inputs[0]) + links.new(combine_reflect1_alpha1_edge1.outputs[0], node_mix.inputs[1]) + links.new(combine_reflect2_alpha2_edge2.outputs[0], node_mix.inputs[2]) + links.new(node_mix.outputs[0], separate_reflect_alpha_edge.inputs[0]) + + combine_base1a_toon1a_sphere1a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.0)) + combine_base2a_toon2a_sphere2a = ng.new_node("ShaderNodeCombineRGB", (-2, -2.25)) + separate_basea_toona_spherea = ng.new_node("ShaderNodeSeparateRGB", (pos_x + 2, -2.0)) + + node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos_x + 1, -2.0)) + links.new(node_input.outputs["Fac"], node_mix.inputs[0]) + links.new(combine_base1a_toon1a_sphere1a.outputs[0], node_mix.inputs[1]) + links.new(combine_base2a_toon2a_sphere2a.outputs[0], node_mix.inputs[2]) + links.new(node_mix.outputs[0], separate_basea_toona_spherea.inputs[0]) + + base_rgb = __blend_color_add("Base", (pos_x, -2.5), " RGB") + __add_sockets("Base", combine_base1a_toon1a_sphere1a.inputs[0], combine_base2a_toon2a_sphere2a.inputs[0], separate_basea_toona_spherea.outputs[0], tag=" A") + __blend_tex_color("Base", (pos_x + 3, -2.5), base_rgb, separate_basea_toona_spherea.outputs[0]) + + toon_rgb = __blend_color_add("Toon", (pos_x, -3.0), " RGB") + __add_sockets("Toon", combine_base1a_toon1a_sphere1a.inputs[1], combine_base2a_toon2a_sphere2a.inputs[1], separate_basea_toona_spherea.outputs[1], tag=" A") + __blend_tex_color("Toon", (pos_x + 3, -3.0), toon_rgb, separate_basea_toona_spherea.outputs[1]) + + sphere_rgb = __blend_color_add("Sphere", (pos_x, -3.5), " RGB") + __add_sockets("Sphere", combine_base1a_toon1a_sphere1a.inputs[2], combine_base2a_toon2a_sphere2a.inputs[2], separate_basea_toona_spherea.outputs[2], tag=" A") + __blend_tex_color("Sphere", (pos_x + 3, -3.5), sphere_rgb, separate_basea_toona_spherea.outputs[2]) + + ng.hide_nodes() + return ng.shader diff --git a/core/mmd/core/translations.py b/core/mmd/core/translations.py new file mode 100644 index 0000000..6574ba0 --- /dev/null +++ b/core/mmd/core/translations.py @@ -0,0 +1,738 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +import re +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple + +import bpy + +from ..translations import DictionaryEnum +from ..utils import convertLRToName, convertNameToLR +from .model import FnModel, Model + +if TYPE_CHECKING: + from ..properties.morph import _MorphBase + from ..properties.root import MMDRoot + from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex + + +class MMDTranslationElementType(Enum): + BONE = "Bones" + MORPH = "Morphs" + MATERIAL = "Materials" + DISPLAY = "Display" + PHYSICS = "Physics" + INFO = "Information" + + +class MMDDataHandlerABC(ABC): + @classmethod + @property + @abstractmethod + def type_name(cls) -> str: + pass + + @classmethod + @abstractmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + pass + + @classmethod + @abstractmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + pass + + @classmethod + @abstractmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + pass + + @classmethod + @abstractmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + pass + + @classmethod + @abstractmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + pass + + @classmethod + @abstractmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + """Returns (name, name_j, name_e)""" + + @classmethod + def is_restorable(cls, mmd_translation_element: "MMDTranslationElement") -> bool: + return (mmd_translation_element.name, mmd_translation_element.name_j, mmd_translation_element.name_e) != cls.get_names(mmd_translation_element) + + @classmethod + def check_data_visible(cls, filter_selected: bool, filter_visible: bool, select: bool, hide: bool) -> bool: + return filter_selected and not select or filter_visible and hide + + @classmethod + def prop_restorable(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str, original_value: str, index: int): + row = layout.row(align=True) + row.prop(mmd_translation_element, prop_name, text="") + + if getattr(mmd_translation_element, prop_name) == original_value: + row.label(text="", icon="BLANK1") + return + + op = row.operator("mmd_tools.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH") + op.index = index + op.prop_name = prop_name + op.restore_value = original_value + + @classmethod + def prop_disabled(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str): + row = layout.row(align=True) + row.enabled = False + row.prop(mmd_translation_element, prop_name, text="") + row.label(text="", icon="BLANK1") + + +class MMDBoneHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.BONE.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="BONE_DATA") + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", pose_bone.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", pose_bone.mmd_bone.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", pose_bone.mmd_bone.name_e, index) + row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.bone.select else "RESTRICT_SELECT_ON") + row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if pose_bone.bone.hide else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) + pose_bone: bpy.types.PoseBone + for index, pose_bone in enumerate(armature_object.pose.bones): + if not any(c.is_visible for c in pose_bone.bone.collections): + continue + + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.BONE.name + mmd_translation_element.object = armature_object + mmd_translation_element.data_path = f"pose.bones[{index}]" + mmd_translation_element.name = pose_bone.name + mmd_translation_element.name_j = pose_bone.mmd_bone.name_j + mmd_translation_element.name_e = pose_bone.mmd_bone.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + mmd_translation_element.object.id_data.data.bones.active = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path).bone + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.BONE.name: + continue + + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + + if cls.check_data_visible(filter_selected, filter_visible, pose_bone.bone.select, pose_bone.bone.hide): + continue + + if check_blank_name(mmd_translation_element.name_j, mmd_translation_element.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + pose_bone.name = name + if name_j is not None: + pose_bone.mmd_bone.name_j = name_j + if name_e is not None: + pose_bone.mmd_bone.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (pose_bone.name, pose_bone.mmd_bone.name_j, pose_bone.mmd_bone.name_e) + + +class MMDMorphHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.MORPH.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="SHAPEKEY_DATA") + prop_row = row.row() + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_restorable(prop_row, mmd_translation_element, "name", morph.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", morph.name_e, index) + row.label(text="", icon="BLANK1") + row.label(text="", icon="BLANK1") + + MORPH_DATA_PATH_EXTRACT = re.compile(r"mmd_root\.(?P[^\[]*)\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + mmd_root: "MMDRoot" = root_object.mmd_root + + for morphs_name, morphs in { + "material_morphs": mmd_root.material_morphs, + "uv_morphs": mmd_root.uv_morphs, + "bone_morphs": mmd_root.bone_morphs, + "vertex_morphs": mmd_root.vertex_morphs, + "group_morphs": mmd_root.group_morphs, + }.items(): + morph: "_MorphBase" + for index, morph in enumerate(morphs): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.MORPH.name + mmd_translation_element.object = root_object + mmd_translation_element.data_path = f"mmd_root.{morphs_name}[{index}]" + mmd_translation_element.name = morph.name + # mmd_translation_element.name_j = None + mmd_translation_element.name_e = morph.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + match = cls.MORPH_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + mmd_translation_element.object.mmd_root.active_morph_type = match["morphs_name"] + mmd_translation_element.object.mmd_root.active_morph = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.MORPH.name: + continue + + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if check_blank_name(morph.name, morph.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + morph.name = name + if name_e is not None: + morph.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (morph.name, "", morph.name_e) + + +class MMDMaterialHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.MATERIAL.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + mesh_object: bpy.types.Object = mmd_translation_element.object + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="MATERIAL_DATA") + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", material.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", material.mmd_material.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", material.mmd_material.name_e, index) + row.prop(mesh_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mesh_object.select_get() else "RESTRICT_SELECT_ON") + row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mesh_object.hide_get() else "HIDE_OFF") + + MATERIAL_DATA_PATH_EXTRACT = re.compile(r"data\.materials\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + checked_materials: Set[bpy.types.Material] = set() + mesh_object: bpy.types.Object + for mesh_object in FnModel.iterate_mesh_objects(mmd_translation.id_data): + material: bpy.types.Material + for index, material in enumerate(mesh_object.data.materials): + if material in checked_materials: + continue + + checked_materials.add(material) + + if not hasattr(material, "mmd_material"): + continue + + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.MATERIAL.name + mmd_translation_element.object = mesh_object + mmd_translation_element.data_path = f"data.materials[{index}]" + mmd_translation_element.name = material.name + mmd_translation_element.name_j = material.mmd_material.name_j + mmd_translation_element.name_e = material.mmd_material.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + id_data: bpy.types.Object = mmd_translation_element.object + bpy.context.view_layer.objects.active = id_data + + match = cls.MATERIAL_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + id_data.active_material_index = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.MATERIAL.name: + continue + + mesh_object: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, mesh_object.select_get(), mesh_object.hide_get()): + continue + + material: bpy.types.Material = mesh_object.path_resolve(mmd_translation_element.data_path) + if check_blank_name(material.mmd_material.name_j, material.mmd_material.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + material.name = name + if name_j is not None: + material.mmd_material.name_j = name_j + if name_e is not None: + material.mmd_material.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + material: bpy.types.Material = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (material.name, material.mmd_material.name_j, material.mmd_material.name_e) + + +class MMDDisplayHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.DISPLAY.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + row = layout.row(align=True) + row.label(text="", icon="GROUP_BONE") + + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", bone_collection.name, index) + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_disabled(prop_row, mmd_translation_element, "name_e") + row.prop(mmd_translation_element.object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mmd_translation_element.object.select_get() else "RESTRICT_SELECT_ON") + row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mmd_translation_element.object.hide_get() else "HIDE_OFF") + + DISPLAY_DATA_PATH_EXTRACT = re.compile(r"data\.collections\[(?P\d*)\]") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) + bone_collection: bpy.types.BoneCollection + for index, bone_collection in enumerate(armature_object.data.collections): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.DISPLAY.name + mmd_translation_element.object = armature_object + mmd_translation_element.data_path = f"data.collections[{index}]" + mmd_translation_element.name = bone_collection.name + # mmd_translation_element.name_j = None + # mmd_translation_element.name_e = None + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + id_data: bpy.types.Object = mmd_translation_element.object + bpy.context.view_layer.objects.active = id_data + + match = cls.DISPLAY_DATA_PATH_EXTRACT.match(mmd_translation_element.data_path) + if not match: + return + + id_data.data.collections.active_index = int(match["index"]) + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.DISPLAY.name: + continue + + obj: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()): + continue + + bone_collection: bpy.types.BoneCollection = obj.path_resolve(mmd_translation_element.data_path) + if check_blank_name(bone_collection.name, ""): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + if name is not None: + bone_collection.name = name + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + bone_collection: bpy.types.BoneCollection = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + return (bone_collection.name, "", "") + + +class MMDPhysicsHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.PHYSICS.name + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + icon = "MESH_ICOSPHERE" + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + icon = "CONSTRAINT" + mmd_object = obj.mmd_joint + + row = layout.row(align=True) + row.label(text="", icon=icon) + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", obj.name, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_j", mmd_object.name_j, index) + cls.prop_restorable(prop_row, mmd_translation_element, "name_e", mmd_object.name_e, index) + row.prop(obj, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if obj.select_get() else "RESTRICT_SELECT_ON") + row.prop(obj, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if obj.hide_get() else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + model = Model(root_object) + + obj: bpy.types.Object + for obj in model.rigidBodies(): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name + mmd_translation_element.object = obj + mmd_translation_element.data_path = "mmd_rigid" + mmd_translation_element.name = obj.name + mmd_translation_element.name_j = obj.mmd_rigid.name_j + mmd_translation_element.name_e = obj.mmd_rigid.name_e + + obj: bpy.types.Object + for obj in model.joints(): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name + mmd_translation_element.object = obj + mmd_translation_element.data_path = "mmd_joint" + mmd_translation_element.name = obj.name + mmd_translation_element.name_j = obj.mmd_joint.name_j + mmd_translation_element.name_e = obj.mmd_joint.name_e + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.PHYSICS.name: + continue + + obj: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, obj.select_get(), obj.hide_get()): + continue + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + if check_blank_name(mmd_object.name_j, mmd_object.name_e): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + if name is not None: + obj.name = name + if name_j is not None: + mmd_object.name_j = name_j + if name_e is not None: + mmd_object.name_e = name_e + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + obj: bpy.types.Object = mmd_translation_element.object + + if FnModel.is_rigid_body_object(obj): + mmd_object = obj.mmd_rigid + elif FnModel.is_joint_object(obj): + mmd_object = obj.mmd_joint + + return (obj.name, mmd_object.name_j, mmd_object.name_e) + + +class MMDInfoHandler(MMDDataHandlerABC): + @classmethod + @property + def type_name(cls) -> str: + return MMDTranslationElementType.INFO.name + + TYPE_TO_ICONS = { + "EMPTY": "EMPTY_DATA", + "ARMATURE": "ARMATURE_DATA", + "MESH": "MESH_DATA", + } + + @classmethod + def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): + info_object: bpy.types.Object = mmd_translation_element.object + row = layout.row(align=True) + row.label(text="", icon=MMDInfoHandler.TYPE_TO_ICONS.get(info_object.type, "OBJECT_DATA")) + prop_row = row.row() + cls.prop_restorable(prop_row, mmd_translation_element, "name", info_object.name, index) + cls.prop_disabled(prop_row, mmd_translation_element, "name") + cls.prop_disabled(prop_row, mmd_translation_element, "name_e") + row.prop(info_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if info_object.select_get() else "RESTRICT_SELECT_ON") + row.prop(info_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if info_object.hide_get() else "HIDE_OFF") + + @classmethod + def collect_data(cls, mmd_translation: "MMDTranslation"): + root_object: bpy.types.Object = mmd_translation.id_data + info_objects = [root_object] + armature_object = FnModel.find_armature_object(root_object) + if armature_object is not None: + info_objects.append(armature_object) + + for info_object in itertools.chain(info_objects, FnModel.iterate_mesh_objects(root_object)): + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element.type = MMDTranslationElementType.INFO.name + mmd_translation_element.object = info_object + mmd_translation_element.data_path = "" + mmd_translation_element.name = info_object.name + # mmd_translation_element.name_j = None + # mmd_translation_element.name_e = None + + @classmethod + def update_index(cls, mmd_translation_element: "MMDTranslationElement"): + bpy.context.view_layer.objects.active = mmd_translation_element.object + + @classmethod + def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): + mmd_translation_element: "MMDTranslationElement" + for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): + if mmd_translation_element.type != MMDTranslationElementType.INFO.name: + continue + + info_object: bpy.types.Object = mmd_translation_element.object + if cls.check_data_visible(filter_selected, filter_visible, info_object.select_get(), info_object.hide_get()): + continue + + if check_blank_name(info_object.name, ""): + continue + + if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): + continue + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index.value = index + + @classmethod + def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): + info_object: bpy.types.Object = mmd_translation_element.object + if name is not None: + info_object.name = name + + @classmethod + def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: + info_object: bpy.types.Object = mmd_translation_element.object + return (info_object.name, "", "") + + +MMD_DATA_HANDLERS: Set[MMDDataHandlerABC] = { + MMDBoneHandler, + MMDMorphHandler, + MMDMaterialHandler, + MMDDisplayHandler, + MMDPhysicsHandler, + MMDInfoHandler, +} + +MMD_DATA_TYPE_TO_HANDLERS: Dict[str, MMDDataHandlerABC] = {h.type_name: h for h in MMD_DATA_HANDLERS} + + +class FnTranslations: + @staticmethod + def apply_translations(root_object: bpy.types.Object): + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + mmd_translation_element_index: "MMDTranslationElementIndex" + for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] + name, name_j, name_e = handler.get_names(mmd_translation_element) + handler.set_names( + mmd_translation_element, + mmd_translation_element.name if mmd_translation_element.name != name else None, + mmd_translation_element.name_j if mmd_translation_element.name_j != name_j else None, + mmd_translation_element.name_e if mmd_translation_element.name_e != name_e else None, + ) + + @staticmethod + def execute_translation_batch(root_object: bpy.types.Object) -> Tuple[Dict[str, str], Optional[bpy.types.Text]]: + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + batch_operation_script = mmd_translation.batch_operation_script + if not batch_operation_script: + return ({}, None) + + translator = DictionaryEnum.get_translator(mmd_translation.dictionary) + + def translate(name: str) -> str: + if translator: + return translator.translate(name, name) + return name + + batch_operation_script_ast = compile(mmd_translation.batch_operation_script, "", "eval") + batch_operation_target: str = mmd_translation.batch_operation_target + + mmd_translation_element_index: "MMDTranslationElementIndex" + for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + + handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] + + name = mmd_translation_element.name + name_j = mmd_translation_element.name_j + name_e = mmd_translation_element.name_e + org_name, org_name_j, org_name_e = handler.get_names(mmd_translation_element) + + # pylint: disable=eval-used + result_name = str( + eval( + batch_operation_script_ast, + {"__builtins__": {}}, + { + "to_english": translate, + "to_mmd_lr": convertLRToName, + "to_blender_lr": convertNameToLR, + "name": name, + "name_j": name_j if name_j != "" else name, + "name_e": name_e if name_e != "" else name, + "org_name": org_name, + "org_name_j": org_name_j, + "org_name_e": org_name_e, + }, + ) + ) + + if batch_operation_target == "BLENDER": + mmd_translation_element.name = result_name + elif batch_operation_target == "JAPANESE": + mmd_translation_element.name_j = result_name + elif batch_operation_target == "ENGLISH": + mmd_translation_element.name_e = result_name + + return (translator.fails, translator.save_fails()) + + @staticmethod + def update_index(mmd_translation: "MMDTranslation"): + if mmd_translation.filtered_translation_element_indices_active_index < 0: + return + + mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices[mmd_translation.filtered_translation_element_indices_active_index] + mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + + MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].update_index(mmd_translation_element) + + @staticmethod + def collect_data(mmd_translation: "MMDTranslation"): + mmd_translation.translation_elements.clear() + for handler in MMD_DATA_HANDLERS: + handler.collect_data(mmd_translation) + + @staticmethod + def update_query(mmd_translation: "MMDTranslation"): + mmd_translation.filtered_translation_element_indices.clear() + mmd_translation.filtered_translation_element_indices_active_index = -1 + + filter_japanese_blank: bool = mmd_translation.filter_japanese_blank + filter_english_blank: bool = mmd_translation.filter_english_blank + + filter_selected: bool = mmd_translation.filter_selected + filter_visible: bool = mmd_translation.filter_visible + + def check_blank_name(name_j: str, name_e: str) -> bool: + return filter_japanese_blank and name_j or filter_english_blank and name_e + + for handler in MMD_DATA_HANDLERS: + if handler.type_name in mmd_translation.filter_types: + handler.update_query(mmd_translation, filter_selected, filter_visible, check_blank_name) + + @staticmethod + def clear_data(mmd_translation: "MMDTranslation"): + mmd_translation.translation_elements.clear() + mmd_translation.filtered_translation_element_indices.clear() + mmd_translation.filtered_translation_element_indices_active_index = -1 + mmd_translation.filter_restorable = False diff --git a/core/mmd/core/vmd/__init__.py b/core/mmd/core/vmd/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/core/vmd/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/core/vmd/importer.py b/core/mmd/core/vmd/importer.py new file mode 100644 index 0000000..07eb925 --- /dev/null +++ b/core/mmd/core/vmd/importer.py @@ -0,0 +1,673 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import math +import os +from typing import Union + +import bpy +from mathutils import Quaternion, Vector + +from ... import utils +from .. import vmd +from ..camera import MMDCamera +from ..lamp import MMDLamp + + +class _MirrorMapper: + def __init__(self, data_map=None): + from ...operators.view import FlipPose + + self.__data_map = data_map + self.__flip_name = FlipPose.flip_name + + def get(self, name, default=None): + return self.__data_map.get(self.__flip_name(name), None) or self.__data_map.get(name, default) + + @staticmethod + def get_location(location): + return (-location[0], location[1], location[2]) + + @staticmethod + def get_rotation(rotation_xyzw): + return (rotation_xyzw[0], -rotation_xyzw[1], -rotation_xyzw[2], rotation_xyzw[3]) + + @staticmethod + def get_rotation3(rotation_xyz): + return (rotation_xyz[0], -rotation_xyz[1], -rotation_xyz[2]) + + +class RenamedBoneMapper: + def __init__(self, armObj=None, rename_LR_bones=True, use_underscore=False, translator=None): + self.__pose_bones = armObj.pose.bones if armObj else None + self.__rename_LR_bones = rename_LR_bones + self.__use_underscore = use_underscore + self.__translator = translator + + def init(self, armObj): + self.__pose_bones = armObj.pose.bones + return self + + def get(self, bone_name, default=None): + bl_bone_name = bone_name + if self.__rename_LR_bones: + bl_bone_name = utils.convertNameToLR(bl_bone_name, self.__use_underscore) + if self.__translator: + bl_bone_name = self.__translator.translate(bl_bone_name) + return self.__pose_bones.get(bl_bone_name, default) + + +class _InterpolationHelper: + def __init__(self, mat): + self.__indices = indices = [0, 1, 2] + l = sorted((-abs(mat[i][j]), i, j) for i in range(3) for j in range(3)) + _, i, j = l[0] + if i != j: + indices[i], indices[j] = indices[j], indices[i] + _, i, j = next(k for k in l if k[1] != i and k[2] != j) + if indices[i] != j: + idx = indices.index(j) + indices[i], indices[idx] = indices[idx], indices[i] + + def convert(self, interpolation_xyz): + return (interpolation_xyz[i] for i in self.__indices) + + +class BoneConverter: + def __init__(self, pose_bone, scale, invert=False): + mat = pose_bone.bone.matrix_local.to_3x3() + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + self.__mat = mat.transposed() + self.__scale = scale + if invert: + self.__mat.invert() + self.convert_interpolation = _InterpolationHelper(self.__mat).convert + + def convert_location(self, location): + return (self.__mat @ Vector(location)) * self.__scale + + def convert_rotation(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized() + + +class BoneConverterPoseMode: + def __init__(self, pose_bone, scale, invert=False): + mat = pose_bone.matrix.to_3x3() + mat[1], mat[2] = mat[2].copy(), mat[1].copy() + self.__mat = mat.transposed() + self.__scale = scale + self.__mat_rot = pose_bone.matrix_basis.to_3x3() + self.__mat_loc = self.__mat_rot @ self.__mat + self.__offset = pose_bone.location.copy() + self.convert_location = self._convert_location + self.convert_rotation = self._convert_rotation + if invert: + self.__mat.invert() + self.__mat_rot.invert() + self.__mat_loc.invert() + self.convert_location = self._convert_location_inverted + self.convert_rotation = self._convert_rotation_inverted + self.convert_interpolation = _InterpolationHelper(self.__mat_loc).convert + + def _convert_location(self, location): + return self.__offset + (self.__mat_loc @ Vector(location)) * self.__scale + + def _convert_rotation(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + rot = Quaternion((self.__mat @ rot.axis) * -1, rot.angle) + return (self.__mat_rot @ rot.to_matrix()).to_quaternion() + + def _convert_location_inverted(self, location): + return (self.__mat_loc @ (Vector(location) - self.__offset)) * self.__scale + + def _convert_rotation_inverted(self, rotation_xyzw): + rot = Quaternion() + rot.x, rot.y, rot.z, rot.w = rotation_xyzw + rot = (self.__mat_rot @ rot.to_matrix()).to_quaternion() + return Quaternion((self.__mat @ rot.axis) * -1, rot.angle).normalized() + + +class _FnBezier: + @classmethod + def from_fcurve(cls, kp0, kp1): + p0, p1, p2, p3 = kp0.co, kp0.handle_right, kp1.handle_left, kp1.co + if p1.x > p3.x: + t = (p3.x - p0.x) / (p1.x - p0.x) + p1 = (1 - t) * p0 + p1 * t + if p0.x > p2.x: + t = (p3.x - p0.x) / (p3.x - p2.x) + p2 = (1 - t) * p3 + p2 * t + return cls(p0, p1, p2, p3) + + def __init__(self, p0, p1, p2, p3): # assuming VMD's bezier or F-Curve's bezier + # assert(p0.x <= p1.x <= p3.x and p0.x <= p2.x <= p3.x) + self._p0, self._p1, self._p2, self._p3 = p0, p1, p2, p3 + + @property + def points(self): + return self._p0, self._p1, self._p2, self._p3 + + def split(self, t): + p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3 + p01t = (1 - t) * p0 + t * p1 + p12t = (1 - t) * p1 + t * p2 + p23t = (1 - t) * p2 + t * p3 + p012t = (1 - t) * p01t + t * p12t + p123t = (1 - t) * p12t + t * p23t + pt = (1 - t) * p012t + t * p123t + return _FnBezier(p0, p01t, p012t, pt), _FnBezier(pt, p123t, p23t, p3), pt + + def evaluate(self, t): + p0, p1, p2, p3 = self._p0, self._p1, self._p2, self._p3 + p01t = (1 - t) * p0 + t * p1 + p12t = (1 - t) * p1 + t * p2 + p23t = (1 - t) * p2 + t * p3 + p012t = (1 - t) * p01t + t * p12t + p123t = (1 - t) * p12t + t * p23t + return (1 - t) * p012t + t * p123t + + def split_by_x(self, x): + return self.split(self.axis_to_t(x)) + + def evaluate_by_x(self, x): + return self.evaluate(self.axis_to_t(x)) + + def axis_to_t(self, val, axis=0): + p0, p1, p2, p3 = self._p0[axis], self._p1[axis], self._p2[axis], self._p3[axis] + a = p3 - p0 + 3 * (p1 - p2) + b = 3 * (p0 - 2 * p1 + p2) + c = 3 * (p1 - p0) + d = p0 - val + return next(self.__find_roots(a, b, c, d)) + + def find_critical(self): + p0, p1, p2, p3 = self._p0.y, self._p1.y, self._p2.y, self._p3.y + p_min, p_max = (p0, p3) if p0 < p3 else (p3, p0) + if p1 > p_max or p1 < p_min or p2 > p_max or p2 < p_min: + a = 3 * (p3 - p0 + 3 * (p1 - p2)) + b = 6 * (p0 - 2 * p1 + p2) + c = 3 * (p1 - p0) + yield from self.__find_roots(0, a, b, c) + + @staticmethod + def __find_roots(a, b, c, d): # a*t*t*t + b*t*t + c*t + d = 0 + # TODO fix precision errors (ex: t=0 and t=1) and improve performance + if a == 0: + if b == 0: + t = -d / c + if 0 <= t <= 1: + yield t + else: + D = c * c - 4 * b * d + if D < 0: + return + D = D**0.5 + b2 = 2 * b + t = (-c + D) / b2 + if 0 <= t <= 1: + yield t + t = (-c - D) / b2 + if 0 <= t <= 1: + yield t + return + + def _sqrt3(v): + return -((-v) ** (1 / 3)) if v < 0 else v ** (1 / 3) + + A = b * c / (6 * a * a) - b * b * b / (27 * a * a * a) - d / (2 * a) + B = c / (3 * a) - b * b / (9 * a * a) + b_3a = -b / (3 * a) + D = A * A + B * B * B + + if D > 0: + D = D**0.5 + t = b_3a + _sqrt3(A + D) + _sqrt3(A - D) + if 0 <= t <= 1: + yield t + elif D == 0: + t = b_3a + _sqrt3(A) * 2 + if 0 <= t <= 1: + yield t + t = b_3a - _sqrt3(A) + if 0 <= t <= 1: + yield t + else: + R = A / (-B * B * B) ** 0.5 + t = b_3a + 2 * (-B) ** 0.5 * math.cos(math.acos(R) / 3) + if 0 <= t <= 1: + yield t + t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) + 2 * math.pi) / 3) + if 0 <= t <= 1: + yield t + t = b_3a + 2 * (-B) ** 0.5 * math.cos((math.acos(R) - 2 * math.pi) / 3) + if 0 <= t <= 1: + yield t + + +class HasAnimationData: + animation_data: bpy.types.AnimData + + +class VMDImporter: + def __init__(self, filepath, scale=1.0, bone_mapper=None, use_pose_mode=False, convert_mmd_camera=True, convert_mmd_lamp=True, frame_margin=5, use_mirror=False, use_NLA=False): + self.__vmdFile = vmd.File() + self.__vmdFile.load(filepath=filepath) + logging.debug(str(self.__vmdFile.header)) + self.__scale = scale + self.__convert_mmd_camera = convert_mmd_camera + self.__convert_mmd_lamp = convert_mmd_lamp + self.__bone_mapper = bone_mapper + self.__bone_util_cls = BoneConverterPoseMode if use_pose_mode else BoneConverter + self.__frame_margin = frame_margin + 1 + self.__mirror = use_mirror + self.__use_NLA = use_NLA + + @staticmethod + def __minRotationDiff(prev_q, curr_q): + t1 = (prev_q.w - curr_q.w) ** 2 + (prev_q.x - curr_q.x) ** 2 + (prev_q.y - curr_q.y) ** 2 + (prev_q.z - curr_q.z) ** 2 + t2 = (prev_q.w + curr_q.w) ** 2 + (prev_q.x + curr_q.x) ** 2 + (prev_q.y + curr_q.y) ** 2 + (prev_q.z + curr_q.z) ** 2 + # t1 = prev_q.rotation_difference(curr_q).angle + # t2 = prev_q.rotation_difference(-curr_q).angle + return -curr_q if t2 < t1 else curr_q + + @staticmethod + def __setInterpolation(bezier, kp0, kp1): + if bezier[0] == bezier[1] and bezier[2] == bezier[3]: + kp0.interpolation = "LINEAR" + else: + kp0.interpolation = "BEZIER" + kp0.handle_right_type = "FREE" + kp1.handle_left_type = "FREE" + d = (kp1.co - kp0.co) / 127.0 + kp0.handle_right = kp0.co + Vector((d.x * bezier[0], d.y * bezier[1])) + kp1.handle_left = kp0.co + Vector((d.x * bezier[2], d.y * bezier[3])) + + @staticmethod + def __fixFcurveHandles(fcurve): + kp0 = fcurve.keyframe_points[0] + kp0.handle_left_type = "FREE" + kp0.handle_left = kp0.co + Vector((-1, 0)) + kp = fcurve.keyframe_points[-1] + kp.handle_right_type = "FREE" + kp.handle_right = kp.co + Vector((1, 0)) + + @staticmethod + def __keyframe_insert_inner(fcurves: bpy.types.ActionFCurves, path: str, index: int, frame: float, value: float): + fcurve = fcurves.find(path, index=index) + if fcurve is None: + fcurve = fcurves.new(path, index=index) + fcurve.keyframe_points.insert(frame, value, options={"FAST"}) + + @staticmethod + def __keyframe_insert(fcurves: bpy.types.ActionFCurves, path: str, frame: float, value: Union[int, float, Vector]): + if isinstance(value, (int, float)): + VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value) + + elif isinstance(value, Vector): + VMDImporter.__keyframe_insert_inner(fcurves, path, 0, frame, value[0]) + VMDImporter.__keyframe_insert_inner(fcurves, path, 1, frame, value[1]) + VMDImporter.__keyframe_insert_inner(fcurves, path, 2, frame, value[2]) + + else: + raise TypeError("Unsupported type: {0}".format(type(value))) + + def __getBoneConverter(self, bone): + converter = self.__bone_util_cls(bone, self.__scale) + mode = bone.rotation_mode + compatible_quaternion = self.__minRotationDiff + + class _ConverterWrap: + convert_location = converter.convert_location + convert_interpolation = converter.convert_interpolation + if mode == "QUATERNION": + convert_rotation = converter.convert_rotation + compatible_rotation = compatible_quaternion + elif mode == "AXIS_ANGLE": + + @staticmethod + def convert_rotation(rot): + (x, y, z), angle = converter.convert_rotation(rot).to_axis_angle() + return (angle, x, y, z) + + @staticmethod + def compatible_rotation(prev, curr): + angle, x, y, z = curr + if prev[1] * x + prev[2] * y + prev[3] * z < 0: + angle, x, y, z = -angle, -x, -y, -z + angle_diff = prev[0] - angle + if abs(angle_diff) > math.pi: + pi_2 = math.pi * 2 + bias = -0.5 if angle_diff < 0 else 0.5 + angle += int(bias + angle_diff / pi_2) * pi_2 + return (angle, x, y, z) + + else: + convert_rotation = lambda rot: converter.convert_rotation(rot).to_euler(mode) + compatible_rotation = lambda prev, curr: curr.make_compatible(prev) or curr + + return _ConverterWrap + + def __assign_action(self, target: Union[bpy.types.ID, HasAnimationData], action: bpy.types.Action): + if target.animation_data is None: + target.animation_data_create() + + if not self.__use_NLA: + target.animation_data.action = action + else: + frame_current = bpy.context.scene.frame_current + target_track: bpy.types.NlaTrack = target.animation_data.nla_tracks.new() + target_track.name = action.name + target_strip = target_track.strips.new(action.name, frame_current, action) + target_strip.blend_type = "COMBINE" + + def __assignToArmature(self, armObj, action_name=None): + boneAnim = self.__vmdFile.boneAnimation + logging.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name) + if len(boneAnim) < 1: + return + + action_name = action_name or armObj.name + action = bpy.data.actions.new(name=action_name) + + extra_frame = 1 if self.__frame_margin > 1 else 0 + + pose_bones = armObj.pose.bones + if self.__bone_mapper: + pose_bones = self.__bone_mapper(armObj) + + _loc = _rot = lambda i: i + if self.__mirror: + pose_bones = _MirrorMapper(pose_bones) + _loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation + + class _Dummy: + pass + + dummy_keyframe_points = iter(lambda: _Dummy, None) + prop_rot_map = {"QUATERNION": "rotation_quaternion", "AXIS_ANGLE": "rotation_axis_angle"} + + bone_name_table = {} + for name, keyFrames in boneAnim.items(): + num_frame = len(keyFrames) + if num_frame < 1: + continue + bone = pose_bones.get(name, None) + if bone is None: + logging.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames)) + continue + logging.info("(bone) frames:%5d name: %s", len(keyFrames), name) + assert bone_name_table.get(bone.name, name) == name + bone_name_table[bone.name] = name + + fcurves = [dummy_keyframe_points] * 7 # x, y, z, r0, r1, r2, (r3) + data_path_rot = prop_rot_map.get(bone.rotation_mode, "rotation_euler") + bone_rotation = getattr(bone, data_path_rot) + default_values = list(bone.location) + list(bone_rotation) + data_path = 'pose.bones["%s"].location' % bone.name + for axis_i in range(3): + fcurves[axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name) + data_path = 'pose.bones["%s"].%s' % (bone.name, data_path_rot) + for axis_i in range(len(bone_rotation)): + fcurves[3 + axis_i] = action.fcurves.new(data_path=data_path, index=axis_i, action_group=bone.name) + + for i in range(len(default_values)): + c = fcurves[i] + c.keyframe_points.add(extra_frame + num_frame) + kp_iter = iter(c.keyframe_points) + if extra_frame: + kp = next(kp_iter) + kp.co = (1, default_values[i]) + kp.interpolation = "LINEAR" + fcurves[i] = kp_iter + + converter = self.__getBoneConverter(bone) + prev_rot = bone_rotation if extra_frame else None + prev_kps, indices = None, tuple(converter.convert_interpolation((0, 16, 32))) + (48,) * len(bone_rotation) + keyFrames.sort(key=lambda x: x.frame_number) + for k, x, y, z, r0, r1, r2, r3 in zip(keyFrames, *fcurves): + frame = k.frame_number + self.__frame_margin + loc = converter.convert_location(_loc(k.location)) + curr_rot = converter.convert_rotation(_rot(k.rotation)) + if prev_rot is not None: + curr_rot = converter.compatible_rotation(prev_rot, curr_rot) + # FIXME the rotation interpolation has slightly different result + # Blender: rot(x) = prev_rot*(1 - bezier(t)) + curr_rot*bezier(t) + # MMD: rot(x) = prev_rot.slerp(curr_rot, factor=bezier(t)) + prev_rot = curr_rot + + x.co = (frame, loc[0]) + y.co = (frame, loc[1]) + z.co = (frame, loc[2]) + r0.co = (frame, curr_rot[0]) + r1.co = (frame, curr_rot[1]) + r2.co = (frame, curr_rot[2]) + r3.co = (frame, curr_rot[-1]) + + curr_kps = (x, y, z, r0, r1, r2, r3) + if prev_kps is not None: + interp = k.interp + for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps): + self.__setInterpolation(interp[idx : idx + 16 : 4], prev_kp, kp) + prev_kps = curr_kps + + for c in action.fcurves: + self.__fixFcurveHandles(c) + + # property animation + propertyAnim = self.__vmdFile.propertyAnimation + if len(propertyAnim) > 0: + logging.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name) + for keyFrame in propertyAnim: + logging.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states) + frame = keyFrame.frame_number + self.__frame_margin + for ikName, enable in keyFrame.ik_states: + bone = pose_bones.get(ikName, None) + if not bone: + continue + + self.__keyframe_insert(action.fcurves, f'pose.bones["{bone.name}"].mmd_ik_toggle', frame, enable) + + self.__assign_action(armObj, action) + + # Ensure IK toggle state is set based on the first frame of VMD animation + if len(propertyAnim) > 0: + # Collect IK states from the first frame + first_frame_ik_states = {} + first_frame = float('inf') + for keyFrame in propertyAnim: + frame_num = keyFrame.frame_number + if frame_num < first_frame: + first_frame = frame_num + for ikName, enable in keyFrame.ik_states: + first_frame_ik_states[ikName] = enable + elif frame_num == first_frame: + for ikName, enable in keyFrame.ik_states: + if ikName not in first_frame_ik_states: + first_frame_ik_states[ikName] = enable + # Set the mmd_ik_toggle property for each bone based on the collected first frame IK states + for ikName, enable in first_frame_ik_states.items(): + bone = pose_bones.get(ikName, None) + if bone and bone.mmd_ik_toggle != enable: + bone.mmd_ik_toggle = enable # This will trigger the _pose_bone_update_mmd_ik_toggle method + + def __assignToMesh(self, meshObj, action_name=None): + shapeKeyAnim = self.__vmdFile.shapeKeyAnimation + logging.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name) + if len(shapeKeyAnim) < 1: + return + + action_name = action_name or meshObj.name + action = bpy.data.actions.new(name=action_name) + + mirror_map = _MirrorMapper(meshObj.data.shape_keys.key_blocks) if self.__mirror else {} + shapeKeyDict = {k: mirror_map.get(k, v) for k, v in meshObj.data.shape_keys.key_blocks.items()} + + from math import ceil, floor + + for name, keyFrames in shapeKeyAnim.items(): + if name not in shapeKeyDict: + logging.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames)) + continue + logging.info("(mesh) frames:%5d name: %s", len(keyFrames), name) + shapeKey = shapeKeyDict[name] + fcurve = action.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name) + fcurve.keyframe_points.add(len(keyFrames)) + keyFrames.sort(key=lambda x: x.frame_number) + for k, v in zip(keyFrames, fcurve.keyframe_points): + v.co = (k.frame_number + self.__frame_margin, k.weight) + v.interpolation = "LINEAR" + weights = tuple(i.weight for i in keyFrames) + shapeKey.slider_min = min(shapeKey.slider_min, floor(min(weights))) + shapeKey.slider_max = max(shapeKey.slider_max, ceil(max(weights))) + + self.__assign_action(meshObj.data.shape_keys, action) + + def __assignToRoot(self, rootObj, action_name=None): + propertyAnim = self.__vmdFile.propertyAnimation + logging.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name) + if len(propertyAnim) < 1: + return + + action_name = action_name or rootObj.name + action = bpy.data.actions.new(name=action_name) + + logging.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim]) + for keyFrame in propertyAnim: + self.__keyframe_insert(action.fcurves, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible)) + + self.__assign_action(rootObj, action) + + @staticmethod + def detectCameraChange(fcurve, threshold=10.0): + frames = list(fcurve.keyframe_points) + frameCount = len(frames) + frames.sort(key=lambda x: x.co[0]) + for i, f in enumerate(frames): + if i + 1 < frameCount: + n = frames[i + 1] + if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold: + f.interpolation = "CONSTANT" + + def __assignToCamera(self, cameraObj, action_name=None): + mmdCameraInstance = MMDCamera.convertToMMDCamera(cameraObj, self.__scale) + mmdCamera = mmdCameraInstance.object() + cameraObj = mmdCameraInstance.camera() + + cameraAnim = self.__vmdFile.cameraAnimation + logging.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name) + if len(cameraAnim) < 1: + return + + action_name = action_name or mmdCamera.name + parent_action = bpy.data.actions.new(name=action_name) + distance_action = bpy.data.actions.new(name=action_name + "_dis") + + _loc = _rot = lambda i: i + if self.__mirror: + _loc, _rot = _MirrorMapper.get_location, _MirrorMapper.get_rotation3 + + 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(len(cameraAnim)) + + prev_kps, indices = None, (0, 8, 4, 12, 12, 12, 16, 20) # x, z, y, rx, ry, rz, dis, fov + cameraAnim.sort(key=lambda x: x.frame_number) + for k, x, y, z, rx, ry, rz, fov, persp, dis in zip(cameraAnim, *(c.keyframe_points for c in fcurves)): + frame = k.frame_number + self.__frame_margin + x.co, z.co, y.co = ((frame, val * self.__scale) for val in _loc(k.location)) + rx.co, rz.co, ry.co = ((frame, val) for val in _rot(k.rotation)) + fov.co = (frame, math.radians(k.angle)) + dis.co = (frame, k.distance * self.__scale) + persp.co = (frame, k.persp) + + persp.interpolation = "CONSTANT" + curr_kps = (x, y, z, rx, ry, rz, dis, fov) + if prev_kps is not None: + interp = k.interp + for idx, prev_kp, kp in zip(indices, prev_kps, curr_kps): + self.__setInterpolation(interp[idx : idx + 4 : 2] + interp[idx + 1 : idx + 4 : 2], prev_kp, kp) + prev_kps = curr_kps + + for fcurve in fcurves: + self.__fixFcurveHandles(fcurve) + if fcurve.data_path == "rotation_euler": + self.detectCameraChange(fcurve) + + self.__assign_action(mmdCamera, parent_action) + self.__assign_action(cameraObj, distance_action) + + @staticmethod + def detectLampChange(fcurve, threshold=0.1): + frames = list(fcurve.keyframe_points) + frameCount = len(frames) + frames.sort(key=lambda x: x.co[0]) + for i, f in enumerate(frames): + f.interpolation = "LINEAR" + if i + 1 < frameCount: + n = frames[i + 1] + if n.co[0] - f.co[0] <= 1.0 and abs(f.co[1] - n.co[1]) > threshold: + f.interpolation = "CONSTANT" + + def __assignToLamp(self, lampObj, action_name=None): + mmdLampInstance = MMDLamp.convertToMMDLamp(lampObj, self.__scale) + mmdLamp = mmdLampInstance.object() + lampObj = mmdLampInstance.lamp() + + lampAnim = self.__vmdFile.lampAnimation + logging.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name) + if len(lampAnim) < 1: + return + + action_name = action_name or mmdLamp.name + color_action = bpy.data.actions.new(name=action_name + "_color") + location_action = bpy.data.actions.new(name=action_name + "_loc") + + _loc = _MirrorMapper.get_location if self.__mirror else lambda i: i + for keyFrame in lampAnim: + frame = keyFrame.frame_number + self.__frame_margin + self.__keyframe_insert(color_action.fcurves, "color", frame, Vector(keyFrame.color)) + self.__keyframe_insert(location_action.fcurves, "location", frame, Vector(_loc(keyFrame.direction)).xzy * -1) + + for fcurve in location_action.fcurves: + self.detectLampChange(fcurve) + + self.__assign_action(lampObj.data, color_action) + self.__assign_action(lampObj, location_action) + + def assign(self, obj, action_name=None): + if obj is None: + return + if action_name is None: + action_name = os.path.splitext(os.path.basename(self.__vmdFile.filepath))[0] + + if MMDCamera.isMMDCamera(obj): + self.__assignToCamera(obj, action_name + "_camera") + elif MMDLamp.isMMDLamp(obj): + self.__assignToLamp(obj, action_name + "_lamp") + elif getattr(obj.data, "shape_keys", None): + self.__assignToMesh(obj, action_name + "_facial") + elif obj.type == "ARMATURE": + self.__assignToArmature(obj, action_name + "_bone") + elif obj.type == "CAMERA" and self.__convert_mmd_camera: + self.__assignToCamera(obj, action_name + "_camera") + elif obj.type == "LAMP" and self.__convert_mmd_lamp: + self.__assignToLamp(obj, action_name + "_lamp") + elif obj.mmd_type == "ROOT": + self.__assignToRoot(obj, action_name + "_display") + else: + pass diff --git a/core/mmd/cycles_converter.py b/core/mmd/cycles_converter.py new file mode 100644 index 0000000..2a8e531 --- /dev/null +++ b/core/mmd/cycles_converter.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +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/core/mmd/operators/__init__.py b/core/mmd/operators/__init__.py new file mode 100644 index 0000000..f3342f2 --- /dev/null +++ b/core/mmd/operators/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py new file mode 100644 index 0000000..23f2d49 --- /dev/null +++ b/core/mmd/operators/material.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy +from bpy.props import BoolProperty, StringProperty +from bpy.types import Operator + +from .. import cycles_converter +from ..core.exceptions import MaterialNotFoundError +from ..core.material import FnMaterial +from ..core.shader import _NodeGroupUtils + + +class ConvertMaterialsForCycles(Operator): + bl_idname = "mmd_tools.convert_materials_for_cycles" + bl_label = "Convert Materials For Cycles" + bl_description = "Convert materials of selected objects for Cycles." + bl_options = {"REGISTER", "UNDO"} + + use_principled: bpy.props.BoolProperty( + name="Convert to Principled BSDF", + description="Convert MMD shader nodes to Principled BSDF as well if enabled", + default=False, + options={"SKIP_SAVE"}, + ) + + clean_nodes: bpy.props.BoolProperty( + name="Clean Nodes", + description="Remove redundant nodes as well if enabled. Disable it to keep node data.", + default=False, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == "MESH"), None) + + def draw(self, context): + layout = self.layout + layout.prop(self, "use_principled") + layout.prop(self, "clean_nodes") + + def execute(self, context): + try: + context.scene.render.engine = "CYCLES" + except: + self.report({"ERROR"}, " * Failed to change to Cycles render engine.") + return {"CANCELLED"} + for obj in (x for x in context.selected_objects if x.type == "MESH"): + cycles_converter.convertToCyclesShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes) + return {"FINISHED"} + + +class ConvertMaterials(Operator): + bl_idname = "mmd_tools.convert_materials" + bl_label = "Convert Materials" + bl_description = "Convert materials of selected objects." + bl_options = {"REGISTER", "UNDO"} + + use_principled: bpy.props.BoolProperty( + name="Convert to Principled BSDF", + description="Convert MMD shader nodes to Principled BSDF as well if enabled", + default=True, + options={"SKIP_SAVE"}, + ) + + clean_nodes: bpy.props.BoolProperty( + name="Clean Nodes", + description="Remove redundant nodes as well if enabled. Disable it to keep node data.", + default=True, + options={"SKIP_SAVE"}, + ) + + subsurface: bpy.props.FloatProperty( + name="Subsurface", + default=0.001, + soft_min=0.000, + soft_max=1.000, + precision=3, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == "MESH"), None) + + def execute(self, context): + for obj in context.selected_objects: + if obj.type != "MESH": + continue + cycles_converter.convertToBlenderShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes, subsurface=self.subsurface) + return {"FINISHED"} + +class ConvertBSDFMaterials(Operator): + bl_idname = 'mmd_tools.convert_bsdf_materials' + bl_label = 'Convert Blender Materials' + bl_description = 'Convert materials of selected objects.' + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return next((x for x in context.selected_objects if x.type == 'MESH'), None) + + def execute(self, context): + for obj in context.selected_objects: + if obj.type != 'MESH': + continue + cycles_converter.convertToMMDShader(obj) + return {'FINISHED'} + +class _OpenTextureBase: + """Create a texture for mmd model material.""" + + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + filepath: StringProperty( + name="File Path", + description="Filepath used for importing the file", + maxlen=1024, + subtype="FILE_PATH", + ) + + use_filter_image: BoolProperty( + default=True, + options={"HIDDEN"}, + ) + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + +class OpenTexture(Operator, _OpenTextureBase): + bl_idname = "mmd_tools.material_open_texture" + bl_label = "Open Texture" + bl_description = "Create main texture of active material" + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.create_texture(self.filepath) + return {"FINISHED"} + + +class RemoveTexture(Operator): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_remove_texture" + bl_label = "Remove Texture" + bl_description = "Remove main texture of active material" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.remove_texture() + return {"FINISHED"} + + +class OpenSphereTextureSlot(Operator, _OpenTextureBase): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_open_sphere_texture" + bl_label = "Open Sphere Texture" + bl_description = "Create sphere texture of active material" + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.create_sphere_texture(self.filepath, context.active_object) + return {"FINISHED"} + + +class RemoveSphereTexture(Operator): + """Create a texture for mmd model material.""" + + bl_idname = "mmd_tools.material_remove_sphere_texture" + bl_label = "Remove Sphere Texture" + bl_description = "Remove sphere texture of active material" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + mat = context.active_object.active_material + fnMat = FnMaterial(mat) + fnMat.remove_sphere_texture() + return {"FINISHED"} + + +class MoveMaterialUp(Operator): + bl_idname = "mmd_tools.move_material_up" + bl_label = "Move Material Up" + bl_description = "Moves selected material one slot up" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + 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 + + def execute(self, context): + obj = context.active_object + current_idx = obj.active_material_index + prev_index = current_idx - 1 + try: + FnMaterial.swap_materials(obj, current_idx, prev_index, reverse=True, swap_slots=True) + except MaterialNotFoundError: + self.report({"ERROR"}, "Materials not found") + return {"CANCELLED"} + obj.active_material_index = prev_index + + return {"FINISHED"} + + +class MoveMaterialDown(Operator): + bl_idname = "mmd_tools.move_material_down" + bl_label = "Move Material Down" + bl_description = "Moves the selected material one slot down" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + 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 + + def execute(self, context): + obj = context.active_object + current_idx = obj.active_material_index + next_index = current_idx + 1 + try: + FnMaterial.swap_materials(obj, current_idx, next_index, reverse=True, swap_slots=True) + except MaterialNotFoundError: + self.report({"ERROR"}, "Materials not found") + return {"CANCELLED"} + obj.active_material_index = next_index + return {"FINISHED"} + + +class EdgePreviewSetup(Operator): + bl_idname = "mmd_tools.edge_preview_setup" + bl_label = "Edge Preview Setup" + bl_description = 'Preview toon edge settings of active model using "Solidify" modifier' + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + action: bpy.props.EnumProperty( + name="Action", + description="Select action", + items=[ + ("CREATE", "Create", "Create toon edge", 0), + ("CLEAN", "Clean", "Clear toon edge", 1), + ], + default="CREATE", + ) + + def execute(self, context): + from ..core.model import FnModel + + root = FnModel.find_root_object(context.active_object) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + if self.action == "CLEAN": + for obj in FnModel.iterate_mesh_objects(root): + self.__clean_toon_edge(obj) + else: + from ..bpyutils import Props + + 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)) + self.report({"INFO"}, "Created %d toon edge(s)" % counts) + return {"FINISHED"} + + def __clean_toon_edge(self, obj): + if "mmd_edge_preview" in obj.modifiers: + obj.modifiers.remove(obj.modifiers["mmd_edge_preview"]) + + if "mmd_edge_preview" in obj.vertex_groups: + obj.vertex_groups.remove(obj.vertex_groups["mmd_edge_preview"]) + + FnMaterial.clean_materials(obj, can_remove=lambda m: m and m.name.startswith("mmd_edge.")) + + def __create_toon_edge(self, obj, scale=1.0): + self.__clean_toon_edge(obj) + materials = obj.data.materials + material_offset = len(materials) + for m in tuple(materials): + if m and m.mmd_material.enabled_toon_edge: + mat_edge = self.__get_edge_material("mmd_edge." + m.name, m.mmd_material.edge_color, materials) + materials.append(mat_edge) + elif material_offset > 1: + mat_edge = self.__get_edge_material("mmd_edge.disabled", (0, 0, 0, 0), materials) + materials.append(mat_edge) + if len(materials) > material_offset: + mod = obj.modifiers.get("mmd_edge_preview", None) + if mod is None: + mod = obj.modifiers.new("mmd_edge_preview", "SOLIDIFY") + mod.material_offset = material_offset + mod.thickness_vertex_group = 1e-3 # avoid overlapped faces + mod.use_flip_normals = True + mod.use_rim = False + mod.offset = 1 + self.__create_edge_preview_group(obj) + mod.thickness = scale + mod.vertex_group = "mmd_edge_preview" + return len(materials) - material_offset + + def __create_edge_preview_group(self, obj): + vertices, materials = obj.data.vertices, obj.data.materials + weight_map = {i: m.mmd_material.edge_weight for i, m in enumerate(materials) if m} + scale_map = {} + 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} + vg_edge_preview = obj.vertex_groups.new(name="mmd_edge_preview") + for i, mi in {v: f.material_index for f in reversed(obj.data.polygons) for v in f.vertices}.items(): + 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): + if mat_name in materials: + return materials[mat_name] + mat = bpy.data.materials.get(mat_name, None) + if mat is None: + mat = bpy.data.materials.new(mat_name) + mmd_mat = mat.mmd_material + # note: edge affects ground shadow + mmd_mat.is_double_sided = mmd_mat.enabled_drop_shadow = False + mmd_mat.enabled_self_shadow_map = mmd_mat.enabled_self_shadow = False + # mmd_mat.enabled_self_shadow_map = True # for blender 2.78+ BI viewport only + mmd_mat.diffuse_color = mmd_mat.specular_color = (0, 0, 0) + mmd_mat.ambient_color = edge_color[:3] + mmd_mat.alpha = edge_color[3] + mmd_mat.edge_color = edge_color + self.__make_shader(mat) + return mat + + def __make_shader(self, m): + m.use_nodes = True + nodes, links = m.node_tree.nodes, m.node_tree.links + + node_shader = nodes.get("mmd_edge_preview", None) + if node_shader is None or not any(s.is_linked for s in node_shader.outputs): + XPOS, YPOS = 210, 110 + nodes.clear() + node_shader = nodes.new("ShaderNodeGroup") + node_shader.name = "mmd_edge_preview" + node_shader.location = (0, 0) + node_shader.width = 200 + node_shader.node_tree = self.__get_edge_preview_shader() + + node_out = nodes.new("ShaderNodeOutputMaterial") + node_out.location = (XPOS * 2, YPOS * 0) + links.new(node_shader.outputs["Shader"], node_out.inputs["Surface"]) + + 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): + 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): + return shader + + ng = _NodeGroupUtils(shader) + + node_input = ng.new_node("NodeGroupInput", (-5, 0)) + node_output = ng.new_node("NodeGroupOutput", (3, 0)) + + ############################################################################ + node_color = ng.new_node("ShaderNodeMixRGB", (-1, -1.5)) + node_color.mute = True + + ng.new_input_socket("Color", node_color.inputs["Color1"]) + + ############################################################################ + node_ray = ng.new_node("ShaderNodeLightPath", (-3, 1.5)) + node_geo = ng.new_node("ShaderNodeNewGeometry", (-3, 0)) + node_max = ng.new_math_node("MAXIMUM", (-2, 1.5)) + node_max.mute = True + node_gt = ng.new_math_node("GREATER_THAN", (-1, 1)) + node_alpha = ng.new_math_node("MULTIPLY", (0, 1)) + node_trans = ng.new_node("ShaderNodeBsdfTransparent", (0, 0)) + node_rgb = ng.new_node("ShaderNodeBackground", (0, -0.5)) + node_mix = ng.new_node("ShaderNodeMixShader", (1, 0.5)) + + links = ng.links + links.new(node_ray.outputs["Is Camera Ray"], node_max.inputs[0]) + links.new(node_ray.outputs["Is Glossy Ray"], node_max.inputs[1]) + links.new(node_max.outputs["Value"], node_gt.inputs[0]) + links.new(node_geo.outputs["Backfacing"], node_gt.inputs[1]) + links.new(node_gt.outputs["Value"], node_alpha.inputs[0]) + links.new(node_alpha.outputs["Value"], node_mix.inputs["Fac"]) + links.new(node_trans.outputs["BSDF"], node_mix.inputs[1]) + links.new(node_rgb.outputs[0], node_mix.inputs[2]) + links.new(node_color.outputs["Color"], node_rgb.inputs["Color"]) + + ng.new_input_socket("Alpha", node_alpha.inputs[1]) + ng.new_output_socket("Shader", node_mix.outputs["Shader"]) + + return shader diff --git a/core/mmd/operators/misc.py b/core/mmd/operators/misc.py new file mode 100644 index 0000000..c59815e --- /dev/null +++ b/core/mmd/operators/misc.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import re + +import bpy + +from .. import utils +from ..bpyutils import FnContext, FnObject +from ..core.bone import FnBone +from ..core.model import FnModel, Model +from ..core.morph import FnMorph + + +class SelectObject(bpy.types.Operator): + bl_idname = "mmd_tools.object_select" + bl_label = "Select Object" + bl_description = "Select the object" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + name: bpy.props.StringProperty( + name="Name", + description="The object name", + default="", + options={"HIDDEN", "SKIP_SAVE"}, + ) + + def execute(self, context): + utils.selectAObject(context.scene.objects[self.name]) + return {"FINISHED"} + + +class MoveObject(bpy.types.Operator, utils.ItemMoveOp): + bl_idname = "mmd_tools.object_move" + bl_label = "Move Object" + bl_description = "Move active object up/down in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + __PREFIX_REGEXP = re.compile(r"(?P[0-9A-Z]{3}_)(?P.*)") + + @classmethod + def set_index(cls, obj, index): + m = cls.__PREFIX_REGEXP.match(obj.name) + name = m.group("name") if m else obj.name + obj.name = "%s_%s" % (utils.int2base(index, 36, 3), name) + + @classmethod + def get_name(cls, obj, prefix=None): + m = cls.__PREFIX_REGEXP.match(obj.name) + name = m.group("name") if m else obj.name + return name[len(prefix) :] if prefix and name.startswith(prefix) else name + + @classmethod + def normalize_indices(cls, objects): + for i, x in enumerate(objects): + cls.set_index(x, i) + + @classmethod + def poll(cls, context): + return context.active_object + + def execute(self, context): + obj = context.active_object + objects = self.__get_objects(obj) + if obj not in objects: + self.report({"ERROR"}, 'Can not move object "%s"' % obj.name) + return {"CANCELLED"} + + objects.sort(key=lambda x: x.name) + self.move(objects, objects.index(obj), self.type) + self.normalize_indices(objects) + return {"FINISHED"} + + def __get_objects(self, obj): + class __MovableList(list): + def move(self, index_old, index_new): + item = self[index_old] + self.remove(item) + self.insert(index_new, item) + + objects = [] + root = FnModel.find_root_object(obj) + if root: + rig = Model(root) + if obj.mmd_type == "NONE" and obj.type == "MESH": + objects = rig.meshes() + elif obj.mmd_type == "RIGID_BODY": + objects = rig.rigidBodies() + elif obj.mmd_type == "JOINT": + objects = rig.joints() + return __MovableList(objects) + + +class CleanShapeKeys(bpy.types.Operator): + bl_idname = "mmd_tools.clean_shape_keys" + bl_label = "Clean Shape Keys" + bl_description = "Remove unused shape keys of selected mesh objects" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return any(o.type == "MESH" for o in context.selected_objects) + + @staticmethod + def __can_remove(key_block): + if key_block.relative_key == key_block: + return False # Basis + for v0, v1 in zip(key_block.relative_key.data, key_block.data): + if v0.co != v1.co: + return False + return True + + def __shape_key_clean(self, obj, key_blocks): + for kb in key_blocks: + if self.__can_remove(kb): + FnObject.mesh_remove_shape_key(obj, kb) + if len(key_blocks) == 1: + FnObject.mesh_remove_shape_key(obj, key_blocks[0]) + + def execute(self, context): + obj: bpy.types.Object + for obj in context.selected_objects: + if obj.type != "MESH" or obj.data.shape_keys is None: + continue + if not obj.data.shape_keys.use_relative: + continue # not be considered yet + self.__shape_key_clean(obj, obj.data.shape_keys.key_blocks) + return {"FINISHED"} + + +class SeparateByMaterials(bpy.types.Operator): + bl_idname = "mmd_tools.separate_by_materials" + bl_label = "Separate By Materials" + bl_options = {"REGISTER", "UNDO"} + + clean_shape_keys: bpy.props.BoolProperty( + name="Clean Shape Keys", + description="Remove unused shape keys of separated objects", + default=True, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "MESH" + + def __separate_by_materials(self, obj): + utils.separateByMaterials(obj) + if self.clean_shape_keys: + bpy.ops.mmd_tools.clean_shape_keys() + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.__separate_by_materials(obj) + else: + bpy.ops.mmd_tools.clear_temp_materials() + bpy.ops.mmd_tools.clear_uv_morph_view() + + # Store the current material names + rig = Model(root) + mat_names = [getattr(mat, "name", None) for mat in rig.materials()] + self.__separate_by_materials(obj) + for mesh in rig.meshes(): + FnMorph.clean_uv_morph_vertex_groups(mesh) + if len(mesh.data.materials) > 0: + mat = mesh.data.materials[0] + idx = mat_names.index(getattr(mat, "name", None)) + MoveObject.set_index(mesh, idx) + + for morph in root.mmd_root.material_morphs: + FnMorph(morph, rig).update_mat_related_mesh() + utils.clearUnusedMeshes() + return {"FINISHED"} + + +class JoinMeshes(bpy.types.Operator): + bl_idname = "mmd_tools.join_meshes" + bl_label = "Join Meshes" + bl_description = "Join the Model meshes into a single one" + bl_options = {"REGISTER", "UNDO"} + + sort_shape_keys: bpy.props.BoolProperty( + name="Sort Shape Keys", + description="Sort shape keys in the order of vertex morph", + default=True, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + bpy.ops.mmd_tools.clear_temp_materials() + bpy.ops.mmd_tools.clear_uv_morph_view() + + # Find all the meshes in mmd_root + rig = Model(root) + meshes_list = sorted(rig.meshes(), key=lambda x: x.name) + if not meshes_list: + self.report({"ERROR"}, "The model does not have any meshes") + return {"CANCELLED"} + active_mesh = meshes_list[0] + + FnContext.select_objects(context, *meshes_list) + FnContext.set_active_object(context, active_mesh) + + # Store the current order of the materials + for m in meshes_list[1:]: + for mat in m.data.materials: + if mat not in active_mesh.data.materials[:]: + active_mesh.data.materials.append(mat) + + # Join selected meshes + bpy.ops.object.join() + + if self.sort_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: + FnMorph(morph, rig).update_mat_related_mesh(active_mesh) + utils.clearUnusedMeshes() + return {"FINISHED"} + + +class AttachMeshesToMMD(bpy.types.Operator): + bl_idname = "mmd_tools.attach_meshes" + bl_label = "Attach Meshes to Model" + bl_description = "Finds existing meshes and attaches them to the selected MMD model" + bl_options = {"REGISTER", "UNDO"} + + add_armature_modifier: bpy.props.BoolProperty(default=True) + + def execute(self, context: bpy.types.Context): + root = FnModel.find_root_object(context.active_object) + if root is None: + self.report({"ERROR"}, "Select a MMD model") + return {"CANCELLED"} + + armObj = FnModel.find_armature_object(root) + if armObj is None: + self.report({"ERROR"}, "Model Armature not found") + return {"CANCELLED"} + + FnModel.attach_mesh_objects(root, context.visible_objects, self.add_armature_modifier) + return {"FINISHED"} + + +class ChangeMMDIKLoopFactor(bpy.types.Operator): + bl_idname = "mmd_tools.change_mmd_ik_loop_factor" + bl_label = "Change MMD IK Loop Factor" + bl_description = "Multiplier for all bones' IK iterations in Blender" + bl_options = {"REGISTER", "UNDO"} + + mmd_ik_loop_factor: bpy.props.IntProperty( + name="MMD IK Loop Factor", + description="Scaling factor of MMD IK loop", + min=1, + soft_max=10, + max=100, + ) + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.active_object) is not None + + def invoke(self, context, event): + root_object = FnModel.find_root_object(context.active_object) + self.mmd_ik_loop_factor = root_object.mmd_root.ik_loop_factor + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + root_object = FnModel.find_root_object(context.active_object) + FnModel.change_mmd_ik_loop_factor(root_object, self.mmd_ik_loop_factor) + return {"FINISHED"} + + +class RecalculateBoneRoll(bpy.types.Operator): + bl_idname = "mmd_tools.recalculate_bone_roll" + bl_label = "Recalculate bone roll" + bl_description = "Recalculate bone roll for arm related bones" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "ARMATURE" + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + c = layout.column() + c.label(text="This operation will break existing f-curve/action.", icon="QUESTION") + c.label(text="Click [OK] to run the operation.") + + def execute(self, context): + arm = context.active_object + FnBone.apply_auto_bone_roll(arm) + return {"FINISHED"} diff --git a/core/mmd/operators/model.py b/core/mmd/operators/model.py new file mode 100644 index 0000000..16fe3ba --- /dev/null +++ b/core/mmd/operators/model.py @@ -0,0 +1,486 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from ..bpyutils import FnContext +from ..core.bone import FnBone, MigrationFnBone +from ..core.model import FnModel, Model + + +class MorphSliderSetup(bpy.types.Operator): + bl_idname = "mmd_tools.morph_slider_setup" + bl_label = "Morph Slider Setup" + bl_description = "Translate MMD morphs of selected object into format usable by Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("CREATE", "Create", "Create placeholder object for morph sliders", "SHAPEKEY_DATA", 0), + ("BIND", "Bind", "Bind morph sliders", "DRIVER", 1), + ("UNBIND", "Unbind", "Unbind morph sliders", "X", 2), + ], + default="CREATE", + ) + + def execute(self, context: bpy.types.Context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object): + rig = Model(root_object) + if self.type == "BIND": + rig.morph_slider.bind() + elif self.type == "UNBIND": + rig.morph_slider.unbind() + else: + rig.morph_slider.create() + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} + + +class CleanRiggingObjects(bpy.types.Operator): + bl_idname = "mmd_tools.clean_rig" + bl_label = "Clean Rig" + 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): + root_object = FnModel.find_root_object(context.active_object) + assert root_object is not None + + rig = Model(root_object) + rig.clean() + FnContext.set_active_object(context, root_object) + return {"FINISHED"} + + +class BuildRig(bpy.types.Operator): + bl_idname = "mmd_tools.build_rig" + bl_label = "Build Rig" + bl_description = "Translate physics of selected object into format usable by Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + non_collision_distance_scale: bpy.props.FloatProperty( + name="Non-Collision Distance Scale", + description="The distance scale for creating extra non-collision constraints while building physics", + min=0, + soft_max=10, + default=1.5, + ) + + collision_margin: bpy.props.FloatProperty( + name="Collision Margin", + description="The collision margin between rigid bodies. If 0, the default value for each shape is adopted.", + unit="LENGTH", + min=0, + soft_max=10, + default=1e-06, + ) + + def execute(self, context): + root_object = FnModel.find_root_object(context.active_object) + + with FnContext.temp_override_active_layer_collection(context, root_object): + rig = Model(root_object) + rig.build(self.non_collision_distance_scale, self.collision_margin) + FnContext.set_active_object(context, root_object) + + return {"FINISHED"} + + +class CleanAdditionalTransformConstraints(bpy.types.Operator): + bl_idname = "mmd_tools.clean_additional_transform" + bl_label = "Clean Additional Transform" + bl_description = "Delete shadow bones of selected object and revert bones to default MMD state" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + 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)) + FnContext.set_active_object(context, active_object) + return {"FINISHED"} + + +class ApplyAdditionalTransformConstraints(bpy.types.Operator): + bl_idname = "mmd_tools.apply_additional_transform" + bl_label = "Apply Additional Transform" + bl_description = "Translate appended bones of selected object for Blender" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + armature_object = FnModel.find_armature_object(root_object) + assert armature_object is not None + + MigrationFnBone.fix_mmd_ik_limit_override(armature_object) + FnBone.apply_additional_transformation(armature_object) + FnContext.set_active_object(context, active_object) + return {"FINISHED"} + + +class SetupBoneFixedAxes(bpy.types.Operator): + bl_idname = "mmd_tools.bone_fixed_axis_setup" + bl_label = "Setup Bone Fixed Axis" + bl_description = "Setup fixed axis of selected bones" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("DISABLE", "Disable", "Disable MMD fixed axis of selected bones", 0), + ("LOAD", "Load", "Load/Enable MMD fixed axis of selected bones from their Y-axis or the only rotatable axis", 1), + ("APPLY", "Apply", "Align bone axes to MMD fixed axis of each bone", 2), + ], + default="LOAD", + ) + + def execute(self, context): + armature_object = context.active_object + if not armature_object or armature_object.type != "ARMATURE": + self.report({"ERROR"}, "Active object is not an armature object") + return {"CANCELLED"} + + if self.type == "APPLY": + FnBone.apply_bone_fixed_axis(armature_object) + FnBone.apply_additional_transformation(armature_object) + else: + FnBone.load_bone_fixed_axis(armature_object, enable=(self.type == "LOAD")) + return {"FINISHED"} + + +class SetupBoneLocalAxes(bpy.types.Operator): + bl_idname = "mmd_tools.bone_local_axes_setup" + bl_label = "Setup Bone Local Axes" + bl_description = "Setup local axes of each bone" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + type: bpy.props.EnumProperty( + name="Type", + description="Select type", + items=[ + ("DISABLE", "Disable", "Disable MMD local axes of selected bones", 0), + ("LOAD", "Load", "Load/Enable MMD local axes of selected bones from their bone axes", 1), + ("APPLY", "Apply", "Align bone axes to MMD local axes of each bone", 2), + ], + default="LOAD", + ) + + def execute(self, context): + armature_object = context.active_object + if not armature_object or armature_object.type != "ARMATURE": + self.report({"ERROR"}, "Active object is not an armature object") + return {"CANCELLED"} + + if self.type == "APPLY": + FnBone.apply_bone_local_axes(armature_object) + FnBone.apply_additional_transformation(armature_object) + else: + FnBone.load_bone_local_axes(armature_object, enable=(self.type == "LOAD")) + return {"FINISHED"} + + +class AddMissingVertexGroupsFromBones(bpy.types.Operator): + bl_idname = "mmd_tools.add_missing_vertex_groups_from_bones" + bl_label = "Add Missing Vertex Groups from Bones" + bl_description = "Add the missing vertex groups to the selected mesh" + bl_options = {"REGISTER", "UNDO"} + + search_in_all_meshes: bpy.props.BoolProperty( + name="Search in all meshes", + description="Search for vertex groups in all meshes", + default=False, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return FnModel.find_root_object(context.active_object) is not None + + def execute(self, context: bpy.types.Context): + active_object: bpy.types.Object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + bone_order_mesh_object = FnModel.find_bone_order_mesh_object(root_object) + if bone_order_mesh_object is None: + return {"CANCELLED"} + + FnModel.add_missing_vertex_groups_from_bones(root_object, bone_order_mesh_object, self.search_in_all_meshes) + + return {"FINISHED"} + + +class CreateMMDModelRoot(bpy.types.Operator): + bl_idname = "mmd_tools.create_mmd_model_root_object" + bl_label = "Create a MMD Model Root Object" + bl_description = "Create a MMD model root object with a basic armature" + bl_options = {"REGISTER", "UNDO"} + + name_j: bpy.props.StringProperty( + name="Name", + description="The name of the MMD model", + default="New MMD Model", + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="The english name of the MMD model", + default="New MMD Model", + ) + scale: bpy.props.FloatProperty( + name="Scale", + description="Scale", + default=0.08, + ) + + def execute(self, context): + rig = Model.create(self.name_j, self.name_e, self.scale, add_root_bone=True) + rig.initialDisplayFrames() + return {"FINISHED"} + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class ConvertToMMDModel(bpy.types.Operator): + bl_idname = "mmd_tools.convert_to_mmd_model" + bl_label = "Convert to a MMD Model" + bl_description = "Convert active armature with its meshes to a MMD model (experimental)" + bl_options = {"REGISTER", "UNDO"} + + ambient_color_source: bpy.props.EnumProperty( + name="Ambient Color Source", + description="Select ambient color source", + items=[ + ("DIFFUSE", "Diffuse", "Diffuse color", 0), + ("MIRROR", "Mirror", 'Mirror color (if property "mirror_color" is available)', 1), + ], + default="DIFFUSE", + ) + edge_threshold: bpy.props.FloatProperty( + name="Edge Threshold", + description="MMD toon edge will not be enabled if freestyle line color alpha less than this value", + min=0, + max=1.001, + precision=3, + step=0.1, + default=0.1, + ) + edge_alpha_min: bpy.props.FloatProperty( + name="Minimum Edge Alpha", + description="Minimum alpha of MMD toon edge color", + min=0, + max=1, + precision=3, + step=0.1, + default=0.5, + ) + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for converting the model", + default=0.08, + ) + convert_material_nodes: bpy.props.BoolProperty( + name="Convert Material Nodes", + default=True, + ) + middle_joint_bones_lock: bpy.props.BoolProperty( + name="Middle Joint Bones Lock", + description="Lock specific bones for backward compatibility.", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "ARMATURE" and obj.mode != "EDIT" + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + # TODO convert some basic MMD properties + armature_object = context.active_object + scale = self.scale + model_name = "New MMD Model" + + root_object = FnModel.find_root_object(armature_object) + if root_object is None or root_object != armature_object.parent: + 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): + if mesh.parent is None: + return False + return mesh.parent == armature_object or __is_child_of_armature(mesh.parent) + + def __is_using_armature(mesh): + for m in mesh.modifiers: + if m.type == "ARMATURE" and m.object == armature_object: + return True + return False + + def __get_root(mesh): + if mesh.parent is None: + return mesh + return __get_root(mesh.parent) + + for x in objects: + if __is_using_armature(x) and not __is_child_of_armature(x): + x_root = __get_root(x) + m = x_root.matrix_world + x_root.parent_type = "OBJECT" + x_root.parent = armature_object + x_root.matrix_world = m + + def __configure_rig(self, context: bpy.types.Context, mmd_model: Model): + root_object = mmd_model.rootObject() + armature_object = mmd_model.armature() + mesh_objects = tuple(mmd_model.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} + 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) + + from ..core.material import FnMaterial + + FnMaterial.set_nodes_are_readonly(not self.convert_material_nodes) + try: + 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 + if self.ambient_color_source == "MIRROR" and hasattr(m, "mirror_color"): + mmd_material.ambient_color = m.mirror_color + else: + mmd_material.ambient_color = [0.5 * c for c in mmd_material.diffuse_color] + + if hasattr(m, "line_color"): # freestyle line color + 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)] + finally: + FnMaterial.set_nodes_are_readonly(False) + from .display_item import DisplayItemQuickSetup + + FnBone.sync_display_item_frames_from_bone_collections(armature_object) + mmd_model.initialDisplayFrames(reset=False) # ensure default frames + DisplayItemQuickSetup.load_facial_items(root_object.mmd_root) + root_object.mmd_root.active_display_item_frame = 0 + + +class ResetObjectVisibility(bpy.types.Operator): + bl_idname = "mmd_tools.reset_object_visibility" + bl_label = "Reset Object Visivility" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context: bpy.types.Context): + 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): + 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 + + mmd_root_object.hide_set(False) + + rigid_group_object = FnModel.find_rigid_group_object(mmd_root_object) + if rigid_group_object: + rigid_group_object.hide_set(True) + + joint_group_object = FnModel.find_joint_group_object(mmd_root_object) + if joint_group_object: + joint_group_object.hide_set(True) + + temporary_group_object = FnModel.find_temporary_group_object(mmd_root_object) + if temporary_group_object: + temporary_group_object.hide_set(True) + + mmd_root.show_meshes = True + mmd_root.show_armature = True + mmd_root.show_temporary_objects = False + mmd_root.show_rigid_bodies = False + mmd_root.show_names_of_rigid_bodies = False + mmd_root.show_joints = False + mmd_root.show_names_of_joints = False + + return {"FINISHED"} + + +class AssembleAll(bpy.types.Operator): + bl_idname = "mmd_tools.assemble_all" + bl_label = "Assemble All" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object) as context: + rig = Model(root_object) + MigrationFnBone.fix_mmd_ik_limit_override(rig.armature()) + FnBone.apply_additional_transformation(rig.armature()) + rig.build() + rig.morph_slider.bind() + + with context.temp_override(selected_objects=[active_object]): + bpy.ops.mmd_tools.sdef_bind() + root_object.mmd_root.use_property_driver = True + + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} + + +class DisassembleAll(bpy.types.Operator): + bl_idname = "mmd_tools.disassemble_all" + bl_label = "Disassemble All" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + active_object = context.active_object + root_object = FnModel.find_root_object(active_object) + assert root_object is not None + + with FnContext.temp_override_active_layer_collection(context, root_object) as context: + root_object.mmd_root.use_property_driver = False + with context.temp_override(selected_objects=[active_object]): + bpy.ops.mmd_tools.sdef_unbind() + + rig = Model(root_object) + rig.morph_slider.unbind() + rig.clean() + FnBone.clean_additional_transformation(rig.armature()) + + FnContext.set_active_object(context, active_object) + + return {"FINISHED"} diff --git a/core/mmd/operators/model_edit.py b/core/mmd/operators/model_edit.py new file mode 100644 index 0000000..ca21046 --- /dev/null +++ b/core/mmd/operators/model_edit.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import itertools +from operator import itemgetter +from typing import Dict, List, Optional, Set + +import bmesh +import bpy + +from ..bpyutils import FnContext +from ..core.model import FnModel, Model + + +class MessageException(Exception): + """Class for error with message.""" + + +class ModelJoinByBonesOperator(bpy.types.Operator): + bl_idname = "mmd_tools.model_join_by_bones" + bl_label = "Model Join by Bones" + bl_options = {"REGISTER", "UNDO"} + + join_type: bpy.props.EnumProperty( + name="Join Type", + items=[ + ("CONNECTED", "Connected", ""), + ("OFFSET", "Keep Offset", ""), + ], + default="OFFSET", + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object + + if context.mode != "POSE": + return False + + if active_object is None: + return False + + if active_object.type != "ARMATURE": + return False + + if len(list(filter(lambda o: o.type == "ARMATURE", context.selected_objects))) < 2: + return False + + return len(context.selected_pose_bones) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + try: + self.join(context) + except MessageException as ex: + self.report(type={"ERROR"}, message=str(ex)) + return {"CANCELLED"} + + return {"FINISHED"} + + def join(self, context: bpy.types.Context): + bpy.ops.object.mode_set(mode="OBJECT") + + parent_root_object = FnModel.find_root_object(context.active_object) + child_root_objects = {FnModel.find_root_object(o) for o in context.selected_objects} + child_root_objects.remove(parent_root_object) + + if parent_root_object is None or len(child_root_objects) == 0: + raise MessageException("No MMD Models selected") + + with FnContext.temp_override_active_layer_collection(context, parent_root_object): + FnModel.join_models(parent_root_object, child_root_objects) + + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.armature.parent_set(type="OFFSET") + + # Connect child bones + if self.join_type == "CONNECTED": + parent_edit_bone: bpy.types.EditBone = context.active_bone + child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + child_edit_bones.remove(parent_edit_bone) + + child_edit_bone: bpy.types.EditBone + for child_edit_bone in child_edit_bones: + child_edit_bone.use_connect = True + + bpy.ops.object.mode_set(mode="POSE") + + +class ModelSeparateByBonesOperator(bpy.types.Operator): + bl_idname = "mmd_tools.model_separate_by_bones" + bl_label = "Model Separate by Bones" + bl_options = {"REGISTER", "UNDO"} + + separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True) + include_descendant_bones: bpy.props.BoolProperty(name="Include Descendant Bones", default=True) + weight_threshold: bpy.props.FloatProperty(name="Weight Threshold", default=0.001, min=0.0, max=1.0, precision=4, subtype="FACTOR") + boundary_joint_owner: bpy.props.EnumProperty( + name="Boundary Joint Owner", + items=[ + ("SOURCE", "Source Model", ""), + ("DESTINATION", "Destination Model", ""), + ], + default="DESTINATION", + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object + + if context.mode != "POSE": + return False + + if active_object is None: + return False + + if active_object.type != "ARMATURE": + return False + + if FnModel.find_root_object(active_object) is None: + return False + + return len(context.selected_pose_bones) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + try: + self.separate(context) + except MessageException as ex: + self.report(type={"ERROR"}, message=str(ex)) + return {"CANCELLED"} + + return {"FINISHED"} + + def separate(self, context: bpy.types.Context): + weight_threshold: float = self.weight_threshold + mmd_scale = 0.08 + + target_armature_object: bpy.types.Object = context.active_object + + bpy.ops.object.mode_set(mode="EDIT") + root_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + + if self.include_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} + + mmd_root_object: bpy.types.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(self.select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys()) + + # separate armature bones + separate_armature_object: Optional[bpy.types.Object] + if self.separate_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) + bpy.ops.object.mode_set(mode="OBJECT") + + # collect separate rigid bodies + separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones} + + boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all + + # collect separate joints + separate_joints: Set[bpy.types.Object] = { + joint_object + for joint_object in mmd_model.joints() + if boundary_joint_owner_condition( + [ + joint_object.rigid_body_constraint.object1 in separate_rigid_bodies, + joint_object.rigid_body_constraint.object2 in separate_rigid_bodies, + ] + ) + } + + separate_mesh_objects: Set[bpy.types.Object] + model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] + if len(mmd_model_mesh_objects) == 0: + separate_mesh_objects = set() + model2separate_mesh_objects = dict() + else: + # select meshes + obj: bpy.types.Object + for obj in context.view_layer.objects: + obj.select_set(obj in mmd_model_mesh_objects) + context.view_layer.objects.active = mmd_model_mesh_objects[0] + + # separate mesh by selected vertices + 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] + bpy.ops.object.mode_set(mode="OBJECT") + + model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects)) + + 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() + + if self.separate_armature: + with context.temp_override( + active_object=separate_model_armature_object, + selected_editable_objects=[separate_model_armature_object, separate_armature_object], + ): + bpy.ops.object.join() + + # add mesh + with context.temp_override( + object=separate_model_armature_object, + selected_editable_objects=[separate_model_armature_object, *separate_mesh_objects], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + # replace mesh armature modifier.object + for separate_mesh in separate_mesh_objects: + armature_modifier: Optional[bpy.types.ArmatureModifier] = next(iter([m for m in separate_mesh.modifiers if m.type == "ARMATURE"]), None) + if armature_modifier is None: + armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") + + armature_modifier.object = separate_model_armature_object + + 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) + + with context.temp_override( + object=separate_model.jointGroupObject(), + selected_editable_objects=[separate_model.jointGroupObject(), *separate_joints], + ): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + # move separate objects to new collection + mmd_layer_collection = FnContext.find_user_layer_collection_by_object(context, mmd_root_object) + assert mmd_layer_collection is not None + + separate_layer_collection = FnContext.find_user_layer_collection_by_object(context, separate_root_object) + assert separate_layer_collection is not None + + if mmd_layer_collection.name != 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) + + FnModel.copy_mmd_root( + separate_root_object, + mmd_root_object, + overwrite=True, + replace_name2values={ + # replace related_mesh property values + "related_mesh": {m.data.name: s.data.name for m, s in model2separate_mesh_objects.items()} + }, + ) + + 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() + 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 + target_bmesh.from_mesh(mesh, face_normals=False) + target_bmesh.select_mode |= {"VERT"} + deform_layer = target_bmesh.verts.layers.deform.verify() + + selected_vertex_count = 0 + vert: bmesh.types.BMVert + for vert in target_bmesh.verts: + vert.select_set(False) + + # Find the largest weight vertex group + weights = [(group_index, weight) for group_index, weight in vert[deform_layer].items() if vertex_groups[group_index].name in deform_bones] + + weights.sort(key=lambda i: vertex_groups[i[0]].name in separate_bones, reverse=True) + weights.sort(key=itemgetter(1), reverse=True) + group_index, weight = next(iter(weights), (0, -1)) + + if weight < weight_threshold: + continue + + if vertex_groups[group_index].name not in separate_bones: + continue + + selected_vertex_count += 1 + vert.select_set(True) + + if selected_vertex_count > 0: + mesh2selected_vertex_count[mesh_object] = selected_vertex_count + target_bmesh.select_flush_mode() + target_bmesh.to_mesh(mesh) + + target_bmesh.clear() + + return mesh2selected_vertex_count diff --git a/core/mmd/operators/morph.py b/core/mmd/operators/morph.py new file mode 100644 index 0000000..1b34420 --- /dev/null +++ b/core/mmd/operators/morph.py @@ -0,0 +1,776 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Optional, cast + +import bpy +from mathutils import Quaternion, Vector + +from ..core.model import FnModel +from .. import bpyutils, utils +from ..core.exceptions import MaterialNotFoundError +from ..core.material import FnMaterial +from ..core.morph import FnMorph +from ..utils import ItemMoveOp, ItemOp + + +# Util functions +def divide_vector_components(vec1, vec2): + if len(vec1) != len(vec2): + raise ValueError("Vectors should have the same number of components") + result = [] + for v1, v2 in zip(vec1, vec2): + if v2 == 0: + if v1 == 0: + v2 = 1 # If we have a 0/0 case we change the divisor to 1 + else: + raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero") + result.append(v1 / v2) + return result + + +def multiply_vector_components(vec1, vec2): + if len(vec1) != len(vec2): + raise ValueError("Vectors should have the same number of components") + result = [] + for v1, v2 in zip(vec1, vec2): + result.append(v1 * v2) + return result + + +def special_division(n1, n2): + """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: + n2 = 1 + else: + raise ZeroDivisionError("Invalid Input: a non-zero value can't be divided by zero") + return n1 / n2 + + +class AddMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_add" + bl_label = "Add Morph" + bl_description = "Add a morph item to active morph list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morphs = getattr(mmd_root, morph_type) + morph, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph) + morph.name = "New Morph" + if morph_type.startswith("uv"): + morph.data_type = "VERTEX_GROUP" + return {"FINISHED"} + + +class RemoveMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_remove" + bl_label = "Remove Morph" + bl_description = "Remove morph item(s) from the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + all: bpy.props.BoolProperty( + name="All", + description="Delete all morph items", + default=False, + options={"SKIP_SAVE"}, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + + morph_type = mmd_root.active_morph_type + if morph_type.startswith("material"): + bpy.ops.mmd_tools.clear_temp_materials() + elif morph_type.startswith("uv"): + bpy.ops.mmd_tools.clear_uv_morph_view() + + morphs = getattr(mmd_root, morph_type) + if self.all: + morphs.clear() + mmd_root.active_morph = 0 + else: + morphs.remove(mmd_root.active_morph) + mmd_root.active_morph = max(0, mmd_root.active_morph - 1) + return {"FINISHED"} + + +class MoveMorph(bpy.types.Operator, ItemMoveOp): + bl_idname = "mmd_tools.morph_move" + bl_label = "Move Morph" + bl_description = "Move active morph item up/down in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + mmd_root.active_morph = self.move( + getattr(mmd_root, mmd_root.active_morph_type), + mmd_root.active_morph, + self.type, + ) + return {"FINISHED"} + + +class CopyMorph(bpy.types.Operator): + bl_idname = "mmd_tools.morph_copy" + bl_label = "Copy Morph" + bl_description = "Make a copy of active morph in the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + + morph_type = mmd_root.active_morph_type + morphs = getattr(mmd_root, morph_type) + morph = ItemOp.get_by_index(morphs, mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer()) + + if morph_type.startswith("vertex"): + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.copy_shape_key(obj, name_orig, name_tmp) + + elif morph_type.startswith("uv"): + if morph.data_type == "VERTEX_GROUP": + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.copy_uv_morph_vertex_groups(obj, name_orig, name_tmp) + + morph_new, mmd_root.active_morph = ItemOp.add_after(morphs, mmd_root.active_morph) + 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 + return {"FINISHED"} + + +class OverwriteBoneMorphsFromActionPose(bpy.types.Operator): + bl_idname = "mmd_tools.morph_overwrite_from_active_action_pose" + bl_label = "Overwrite Bone Morphs from active Action Pose" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + root = FnModel.find_root_object(context.active_object) + FnMorph.overwrite_bone_morphs_from_action_pose(FnModel.find_armature_object(root)) + + return {"FINISHED"} + + +class AddMorphOffset(bpy.types.Operator): + bl_idname = "mmd_tools.morph_offset_add" + bl_label = "Add Morph Offset" + bl_description = "Add a morph offset item to the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + item, morph.active_data = ItemOp.add_after(morph.data, morph.active_data) + + if morph_type.startswith("material"): + if obj.type == "MESH" and obj.mmd_type == "NONE": + item.related_mesh = obj.data.name + active_material = obj.active_material + if active_material and "_temp" not in active_material.name: + item.material = active_material.name + + elif morph_type.startswith("bone"): + pose_bone = context.active_pose_bone + if pose_bone: + item.bone = pose_bone.name + item.location = pose_bone.location + item.rotation = pose_bone.rotation_quaternion + + return {"FINISHED"} + + +class RemoveMorphOffset(bpy.types.Operator): + bl_idname = "mmd_tools.morph_offset_remove" + bl_label = "Remove Morph Offset" + bl_description = "Remove morph offset item(s) from the list" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + all: bpy.props.BoolProperty( + name="All", + description="Delete all morph offset items", + default=False, + options={"SKIP_SAVE"}, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + morph_type = mmd_root.active_morph_type + morph = ItemOp.get_by_index(getattr(mmd_root, morph_type), mmd_root.active_morph) + if morph is None: + return {"CANCELLED"} + + if morph_type.startswith("material"): + bpy.ops.mmd_tools.clear_temp_materials() + + if self.all: + if morph_type.startswith("vertex"): + for obj in FnModel.iterate_mesh_objects(root): + FnMorph.remove_shape_key(obj, 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) + return {"FINISHED"} + morph.data.clear() + morph.active_data = 0 + else: + morph.data.remove(morph.active_data) + morph.active_data = max(0, morph.active_data - 1) + return {"FINISHED"} + + +class InitMaterialOffset(bpy.types.Operator): + bl_idname = "mmd_tools.material_morph_offset_init" + bl_label = "Init Material Offset" + bl_description = "Set all offset values to target value" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + target_value: bpy.props.FloatProperty( + name="Target Value", + description="Target value", + default=0, + ) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + val = self.target_value + mat_data.diffuse_color = mat_data.edge_color = (val,) * 4 + 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 + return {"FINISHED"} + + +class ApplyMaterialOffset(bpy.types.Operator): + bl_idname = "mmd_tools.apply_material_morph_offset" + bl_label = "Apply Material Offset" + bl_description = "Calculates the offsets and apply them, then the temporary material is removed" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + if not mat_data.related_mesh: + self.report({"ERROR"}, "You need to choose a Related Mesh first") + return {"CANCELLED"} + meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh) + if meshObj is None: + self.report({"ERROR"}, "The model mesh can't be found") + return {"CANCELLED"} + try: + work_mat_name = mat_data.material + "_temp" + work_mat, base_mat = FnMaterial.swap_materials(meshObj, work_mat_name, mat_data.material) + except MaterialNotFoundError: + self.report({"ERROR"}, "Material not found") + return {"CANCELLED"} + + base_mmd_mat = base_mat.mmd_material + work_mmd_mat = work_mat.mmd_material + + if mat_data.offset_type == "MULT": + try: + diffuse_offset = divide_vector_components(work_mmd_mat.diffuse_color, base_mmd_mat.diffuse_color) + [special_division(work_mmd_mat.alpha, base_mmd_mat.alpha)] + specular_offset = divide_vector_components(work_mmd_mat.specular_color, base_mmd_mat.specular_color) + edge_offset = divide_vector_components(work_mmd_mat.edge_color, base_mmd_mat.edge_color) + mat_data.diffuse_color = diffuse_offset + mat_data.specular_color = specular_offset + mat_data.shininess = special_division(work_mmd_mat.shininess, base_mmd_mat.shininess) + mat_data.ambient_color = divide_vector_components(work_mmd_mat.ambient_color, base_mmd_mat.ambient_color) + mat_data.edge_color = edge_offset + mat_data.edge_weight = special_division(work_mmd_mat.edge_weight, base_mmd_mat.edge_weight) + + except ZeroDivisionError: + mat_data.offset_type = "ADD" # If there is any 0 division we automatically switch it to type ADD + except ValueError: + self.report({"ERROR"}, "An unexpected error happened") + # We should stop on our tracks and re-raise the exception + raise + + if mat_data.offset_type == "ADD": + diffuse_offset = list(work_mmd_mat.diffuse_color - base_mmd_mat.diffuse_color) + [work_mmd_mat.alpha - base_mmd_mat.alpha] + specular_offset = list(work_mmd_mat.specular_color - base_mmd_mat.specular_color) + edge_offset = Vector(work_mmd_mat.edge_color) - Vector(base_mmd_mat.edge_color) + mat_data.diffuse_color = diffuse_offset + mat_data.specular_color = specular_offset + mat_data.shininess = work_mmd_mat.shininess - base_mmd_mat.shininess + mat_data.ambient_color = work_mmd_mat.ambient_color - base_mmd_mat.ambient_color + mat_data.edge_color = list(edge_offset) + 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) + return {"FINISHED"} + + +class CreateWorkMaterial(bpy.types.Operator): + bl_idname = "mmd_tools.create_work_material" + bl_label = "Create Work Material" + bl_description = "Creates a temporary material to edit this offset" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + morph = mmd_root.material_morphs[mmd_root.active_morph] + mat_data = morph.data[morph.active_data] + + if not mat_data.related_mesh: + self.report({"ERROR"}, "You need to choose a Related Mesh first") + return {"CANCELLED"} + meshObj = FnModel.find_mesh_object_by_name(morph.id_data, mat_data.related_mesh) + if meshObj is None: + self.report({"ERROR"}, "The model mesh can't be found") + return {"CANCELLED"} + + base_mat = meshObj.data.materials.get(mat_data.material, None) + if base_mat is None: + self.report({"ERROR"}, 'Material "%s" not found' % mat_data.material) + return {"CANCELLED"} + + work_mat_name = base_mat.name + "_temp" + if work_mat_name in bpy.data.materials: + self.report({"ERROR"}, 'Temporary material "%s" is in use' % work_mat_name) + return {"CANCELLED"} + + work_mat = base_mat.copy() + work_mat.name = work_mat_name + meshObj.data.materials.append(work_mat) + FnMaterial.swap_materials(meshObj, base_mat.name, work_mat.name) + base_mmd_mat = base_mat.mmd_material + work_mmd_mat = work_mat.mmd_material + work_mmd_mat.material_id = -1 + + # Apply the offsets + if mat_data.offset_type == "MULT": + diffuse_offset = multiply_vector_components(base_mmd_mat.diffuse_color, mat_data.diffuse_color[0:3]) + specular_offset = multiply_vector_components(base_mmd_mat.specular_color, mat_data.specular_color) + edge_offset = multiply_vector_components(base_mmd_mat.edge_color, mat_data.edge_color) + ambient_offset = multiply_vector_components(base_mmd_mat.ambient_color, mat_data.ambient_color) + work_mmd_mat.diffuse_color = diffuse_offset + work_mmd_mat.alpha *= mat_data.diffuse_color[3] + work_mmd_mat.specular_color = specular_offset + work_mmd_mat.shininess *= mat_data.shininess + work_mmd_mat.ambient_color = ambient_offset + work_mmd_mat.edge_color = edge_offset + work_mmd_mat.edge_weight *= mat_data.edge_weight + elif mat_data.offset_type == "ADD": + diffuse_offset = Vector(base_mmd_mat.diffuse_color) + Vector(mat_data.diffuse_color[0:3]) + specular_offset = Vector(base_mmd_mat.specular_color) + Vector(mat_data.specular_color) + edge_offset = Vector(base_mmd_mat.edge_color) + Vector(mat_data.edge_color) + ambient_offset = Vector(base_mmd_mat.ambient_color) + Vector(mat_data.ambient_color) + work_mmd_mat.diffuse_color = list(diffuse_offset) + work_mmd_mat.alpha += mat_data.diffuse_color[3] + work_mmd_mat.specular_color = list(specular_offset) + work_mmd_mat.shininess += mat_data.shininess + work_mmd_mat.ambient_color = list(ambient_offset) + work_mmd_mat.edge_color = list(edge_offset) + work_mmd_mat.edge_weight += mat_data.edge_weight + + return {"FINISHED"} + + +class ClearTempMaterials(bpy.types.Operator): + bl_idname = "mmd_tools.clear_temp_materials" + bl_label = "Clear Temp Materials" + bl_description = "Clears all the temporary materials" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + for meshObj in FnModel.iterate_mesh_objects(root): + + def __pre_remove(m): + if m and "_temp" in m.name: + base_mat_name = m.name.split("_temp")[0] + try: + FnMaterial.swap_materials(meshObj, m.name, base_mat_name) + return True + except MaterialNotFoundError: + self.report({"WARNING"}, "Base material for %s was not found" % m.name) + return False + + FnMaterial.clean_materials(meshObj, can_remove=__pre_remove) + return {"FINISHED"} + + +class ViewBoneMorph(bpy.types.Operator): + bl_idname = "mmd_tools.view_bone_morph" + bl_label = "View Bone Morph" + bl_description = "View the result of active bone morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + utils.selectSingleBone(context, armature, None, True) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + for morph_data in morph.data: + p_bone: Optional[bpy.types.PoseBone] = armature.pose.bones.get(morph_data.bone, None) + if p_bone: + p_bone.bone.select = True + mtx = (p_bone.matrix_basis.to_3x3() @ Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix()).to_4x4() + mtx.translation = p_bone.location + morph_data.location + p_bone.matrix_basis = mtx + return {"FINISHED"} + + +class ClearBoneMorphView(bpy.types.Operator): + bl_idname = "mmd_tools.clear_bone_morph_view" + bl_label = "Clear Bone Morph View" + bl_description = "Reset transforms of all bones to their default values" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + armature = FnModel.find_armature_object(root) + for p_bone in armature.pose.bones: + p_bone.matrix_basis.identity() + return {"FINISHED"} + + +class ApplyBoneMorph(bpy.types.Operator): + bl_idname = "mmd_tools.apply_bone_morph" + bl_label = "Apply Bone Morph" + bl_description = "Apply current pose to active bone morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + armature = FnModel.find_armature_object(root) + mmd_root = root.mmd_root + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph.data.clear() + morph.active_data = 0 + for p_bone in armature.pose.bones: + if p_bone.location.length > 0 or p_bone.matrix_basis.decompose()[1].angle > 0: + item = morph.data.add() + item.bone = p_bone.name + item.location = p_bone.location + item.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion() + p_bone.bone.select = True + else: + p_bone.bone.select = False + return {"FINISHED"} + + +class SelectRelatedBone(bpy.types.Operator): + bl_idname = "mmd_tools.select_bone_morph_offset_bone" + bl_label = "Select Related Bone" + bl_description = "Select the bone assigned to this offset in the armature" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + utils.selectSingleBone(context, armature, morph_data.bone) + return {"FINISHED"} + + +class EditBoneOffset(bpy.types.Operator): + bl_idname = "mmd_tools.edit_bone_morph_offset" + bl_label = "Edit Related Bone" + bl_description = "Applies the location and rotation of this offset to the bone" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + p_bone = armature.pose.bones[morph_data.bone] + mtx = Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix().to_4x4() + mtx.translation = morph_data.location + p_bone.matrix_basis = mtx + utils.selectSingleBone(context, armature, p_bone.name) + return {"FINISHED"} + + +class ApplyBoneOffset(bpy.types.Operator): + bl_idname = "mmd_tools.apply_bone_morph_offset" + bl_label = "Apply Bone Morph Offset" + bl_description = "Stores the current bone location and rotation into this offset" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + armature = FnModel.find_armature_object(root) + assert armature is not None + morph = mmd_root.bone_morphs[mmd_root.active_morph] + morph_data = morph.data[morph.active_data] + 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() + return {"FINISHED"} + + +class ViewUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.view_uv_morph" + bl_label = "View UV Morph" + bl_description = "View the result of active UV morph on current mesh object" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + mmd_root = root.mmd_root + + meshes = tuple(FnModel.iterate_mesh_objects(root)) + if len(meshes) == 1: + obj = meshes[0] + elif obj not in meshes: + self.report({"ERROR"}, "Please select a mesh object") + return {"CANCELLED"} + meshObj = obj + + bpy.ops.mmd_tools.clear_uv_morph_view() + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + morph = mmd_root.uv_morphs[mmd_root.active_morph] + uv_textures = mesh.uv_layers + + base_uv_layers = [l for l in mesh.uv_layers if not l.name.startswith("_")] + if morph.uv_index >= len(base_uv_layers): + self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index) + return {"CANCELLED"} + + uv_layer_name = base_uv_layers[morph.uv_index].name + if morph.uv_index == 0 or uv_textures.active.name not in {uv_layer_name, "_" + uv_layer_name}: + uv_textures.active = uv_textures[uv_layer_name] + + uv_layer_name = uv_textures.active.name + uv_tex = uv_textures.new(name="__uv.%s" % uv_layer_name) + if uv_tex is None: + self.report({"ERROR"}, "Failed to create a temporary uv layer") + return {"CANCELLED"} + + offsets = FnMorph.get_uv_morph_offset_map(meshObj, morph).items() + offsets = {k: getattr(Vector(v), "zw" if uv_layer_name.startswith("_") else "xy") for k, v in offsets} + if len(offsets) > 0: + base_uv_data = mesh.uv_layers.active.data + temp_uv_data = mesh.uv_layers[uv_tex.name].data + for i, l in enumerate(mesh.loops): + select = temp_uv_data[i].select = l.vertex_index in offsets + if select: + temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index] + + uv_textures.active = uv_tex + uv_tex.active_render = True + meshObj.hide_set(False) + meshObj.select_set(selected) + return {"FINISHED"} + + +class ClearUVMorphView(bpy.types.Operator): + bl_idname = "mmd_tools.clear_uv_morph_view" + bl_label = "Clear UV Morph View" + bl_description = "Clear all temporary data of UV morphs" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + assert root is not None + for m in FnModel.iterate_mesh_objects(root): + mesh = m.data + uv_textures = getattr(mesh, "uv_textures", mesh.uv_layers) + for t in uv_textures: + if t.name.startswith("__uv."): + uv_textures.remove(t) + if len(uv_textures) > 0: + uv_textures[0].active_render = True + uv_textures.active_index = 0 + + animation_data = mesh.animation_data + if animation_data: + nla_tracks = animation_data.nla_tracks + for t in nla_tracks: + if t.name.startswith("__uv."): + nla_tracks.remove(t) + if animation_data.action and animation_data.action.name.startswith("__uv."): + animation_data.action = None + if animation_data.action is None and len(nla_tracks) == 0: + mesh.animation_data_clear() + + for act in bpy.data.actions: + if act.name.startswith("__uv.") and act.users < 1: + bpy.data.actions.remove(act) + return {"FINISHED"} + + +class EditUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.edit_uv_morph" + bl_label = "Edit UV Morph" + bl_description = "Edit UV morph on a temporary UV layer (use UV Editor to edit the result)" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + obj = context.active_object + meshObj = obj + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_mode(type="VERT", action="ENABLE") + bpy.ops.mesh.reveal() # unhide all vertices + bpy.ops.mesh.select_all(action="DESELECT") + bpy.ops.object.mode_set(mode="OBJECT") + + vertices = mesh.vertices + for l, d in zip(mesh.loops, mesh.uv_layers.active.data): + if d.select: + vertices[l.vertex_index].select = True + + polygons = mesh.polygons + polygons.active = getattr(next((p for p in polygons if all(vertices[i].select for i in p.vertices)), None), "index", polygons.active) + + bpy.ops.object.mode_set(mode="EDIT") + meshObj.select_set(selected) + return {"FINISHED"} + + +class ApplyUVMorph(bpy.types.Operator): + bl_idname = "mmd_tools.apply_uv_morph" + bl_label = "Apply UV Morph" + bl_description = "Calculate the UV offsets of selected vertices and apply to active UV morph" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + 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): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + meshObj = obj + + selected = meshObj.select_get() + with bpyutils.select_object(meshObj): + mesh = cast(bpy.types.Mesh, meshObj.data) + morph = mmd_root.uv_morphs[mmd_root.active_morph] + + base_uv_name = mesh.uv_layers.active.name[5:] + if base_uv_name not in mesh.uv_layers: + self.report({"ERROR"}, ' * UV map "%s" not found' % base_uv_name) + return {"CANCELLED"} + + base_uv_data = mesh.uv_layers[base_uv_name].data + temp_uv_data = mesh.uv_layers.active.data + axis_type = "ZW" if base_uv_name.startswith("_") else "XY" + + from collections import namedtuple + + __OffsetData = namedtuple("OffsetData", "index, offset") + offsets = {} + vertices = mesh.vertices + for l, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data): + if vertices[l.vertex_index].select and l.vertex_index not in offsets: + dx, dy = i1.uv - i0.uv + if abs(dx) > 0.0001 or abs(dy) > 0.0001: + offsets[l.vertex_index] = __OffsetData(l.vertex_index, (dx, dy, dx, dy)) + + FnMorph.store_uv_morph_data(meshObj, morph, offsets.values(), axis_type) + morph.data_type = "VERTEX_GROUP" + + meshObj.select_set(selected) + return {"FINISHED"} + + +class CleanDuplicatedMaterialMorphs(bpy.types.Operator): + bl_idname = "mmd_tools.clean_duplicated_material_morphs" + bl_label = "Clean Duplicated Material Morphs" + bl_description = "Clean duplicated material morphs" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.active_object) is not None + + def execute(self, context: bpy.types.Context): + mmd_root_object = FnModel.find_root_object(context.active_object) + FnMorph.clean_duplicated_material_morphs(mmd_root_object) + + return {"FINISHED"} diff --git a/core/mmd/operators/rigid_body.py b/core/mmd/operators/rigid_body.py new file mode 100644 index 0000000..22e3515 --- /dev/null +++ b/core/mmd/operators/rigid_body.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import math +from typing import Dict, Optional, Tuple, cast + +import bpy +from mathutils import Euler, Vector + +from .. import utils +from ..bpyutils import FnContext, Props +from ..core import rigid_body +from ..core.model import FnModel, Model +from ..core.rigid_body import FnRigidBody + + +class SelectRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_select" + bl_label = "Select Rigid Body" + bl_description = "Select similar rigidbody objects which have the same property values with active rigidbody object" + bl_options = {"REGISTER", "UNDO"} + + properties: bpy.props.EnumProperty( + name="Properties", + description="Select the properties to be compared", + options={"ENUM_FLAG"}, + items=[ + ("collision_group_number", "Collision Group", "Collision group", 1), + ("collision_group_mask", "Collision Group Mask", "Collision group mask", 2), + ("type", "Rigid Type", "Rigid type", 4), + ("shape", "Shape", "Collision shape", 8), + ("bone", "Bone", "Target bone", 16), + ], + default=set(), + ) + hide_others: bpy.props.BoolProperty( + name="Hide Others", + description="Hide the rigidbody object which does not have the same property values with active rigidbody object", + default=False, + ) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + @classmethod + def poll(cls, context): + return FnModel.is_rigid_body_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + if root is None: + self.report({"ERROR"}, "The model root can't be found") + return {"CANCELLED"} + + selection = set(FnModel.iterate_rigid_body_objects(root)) + + for prop_name in self.properties: + prop_value = getattr(obj.mmd_rigid, prop_name) + if prop_name == "collision_group_mask": + prop_value = tuple(prop_value) + for i in selection.copy(): + if tuple(i.mmd_rigid.collision_group_mask) != prop_value: + selection.remove(i) + if self.hide_others: + i.select_set(False) + i.hide_set(True) + else: + for i in selection.copy(): + if getattr(i.mmd_rigid, prop_name) != prop_value: + selection.remove(i) + if self.hide_others: + i.select_set(False) + i.hide_set(True) + + for i in selection: + i.hide_set(False) + i.select_set(True) + + return {"FINISHED"} + + +class AddRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_add" + bl_label = "Add Rigid Body" + bl_description = "Add Rigid Bodies to selected bones" + bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"} + + name_j: bpy.props.StringProperty( + name="Name", + description="The name of rigid body ($name_j means use the japanese name of target bone)", + default="$name_j", + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="The english name of rigid body ($name_e means use the english name of target bone)", + default="$name_e", + ) + collision_group_number: bpy.props.IntProperty( + name="Collision Group", + description="The collision group of the object", + min=0, + max=15, + ) + collision_group_mask: bpy.props.BoolVectorProperty( + name="Collision Group Mask", + description="The groups the object can not collide with", + size=16, + subtype="LAYER", + ) + rigid_type: bpy.props.EnumProperty( + name="Rigid Type", + description="Select rigid type", + items=[ + (str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1), + (str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2), + (str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3), + ], + ) + rigid_shape: bpy.props.EnumProperty( + name="Shape", + description="Select the collision shape", + items=[ + ("SPHERE", "Sphere", "", 1), + ("BOX", "Box", "", 2), + ("CAPSULE", "Capsule", "", 3), + ], + ) + size: bpy.props.FloatVectorProperty( + name="Size", + description="Size of the object, the values will multiply the length of target bone", + subtype="XYZ", + size=3, + min=0, + default=[0.6, 0.6, 0.6], + ) + mass: bpy.props.FloatProperty( + name="Mass", + description="How much the object 'weights' irrespective of gravity", + min=0.001, + default=1, + ) + friction: bpy.props.FloatProperty( + name="Friction", + description="Resistance of object to movement", + min=0, + soft_max=1, + default=0.5, + ) + bounce: bpy.props.FloatProperty( + name="Restitution", + description="Tendency of object to bounce after colliding with another (0 = stays still, 1 = perfectly elastic)", + min=0, + soft_max=1, + ) + linear_damping: bpy.props.FloatProperty( + name="Linear Damping", + description="Amount of linear velocity that is lost over time", + min=0, + max=1, + default=0.04, + ) + angular_damping: bpy.props.FloatProperty( + name="Angular Damping", + description="Amount of angular velocity that is lost over time", + min=0, + max=1, + default=0.1, + ) + + def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None): + name_j: str = self.name_j + name_e: str = self.name_e + size = self.size.copy() + loc = Vector((0.0, 0.0, 0.0)) + rot = Euler((0.0, 0.0, 0.0)) + bone_name: Optional[str] = None + + if pose_bone is None: + size *= getattr(root_object, Props.empty_display_size) + else: + bone_name = pose_bone.name + mmd_bone = pose_bone.mmd_bone + name_j = name_j.replace("$name_j", mmd_bone.name_j or bone_name) + name_e = name_e.replace("$name_e", mmd_bone.name_e or bone_name) + + target_bone = pose_bone.bone + loc = (target_bone.head_local + target_bone.tail_local) / 2 + rot = target_bone.matrix_local.to_euler("YXZ") + rot.rotate_axis("X", math.pi / 2) + + size *= target_bone.length + if 1: + pass # bypass resizing + elif self.rigid_shape == "SPHERE": + size.x *= 0.8 + elif self.rigid_shape == "BOX": + size.x /= 3 + size.y /= 3 + size.z *= 0.8 + elif self.rigid_shape == "CAPSULE": + size.x /= 3 + + return FnRigidBody.setup_rigid_body_object( + obj=FnRigidBody.new_rigid_body_object(context, FnModel.ensure_rigid_group_object(context, root_object)), + shape_type=rigid_body.shapeType(self.rigid_shape), + location=loc, + rotation=rot, + size=size, + dynamics_type=int(self.rigid_type), + name=name_j, + name_e=name_e, + collision_group_number=self.collision_group_number, + collision_group_mask=self.collision_group_mask, + mass=self.mass, + friction=self.friction, + bounce=self.bounce, + linear_damping=self.linear_damping, + angular_damping=self.angular_damping, + bone=bone_name, + ) + + @classmethod + def poll(cls, context): + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + return False + + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return False + + return True + + def execute(self, context): + active_object = context.active_object + + root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) + armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) + + if active_object != armature_object: + FnContext.select_single_object(context, root_object).select_set(False) + elif armature_object.mode != "POSE": + bpy.ops.object.mode_set(mode="POSE") + + selected_pose_bones = [] + if context.selected_pose_bones: + selected_pose_bones = context.selected_pose_bones + + armature_object.select_set(False) + if len(selected_pose_bones) > 0: + for pose_bone in selected_pose_bones: + rigid = self.__add_rigid_body(context, root_object, pose_bone) + rigid.select_set(True) + else: + rigid = self.__add_rigid_body(context, root_object) + rigid.select_set(True) + return {"FINISHED"} + + def invoke(self, context, event): + no_bone = True + if context.selected_bones and len(context.selected_bones) > 0: + no_bone = False + elif context.selected_pose_bones and len(context.selected_pose_bones) > 0: + no_bone = False + + if no_bone: + self.name_j = "Rigid" + self.name_e = "Rigid_e" + else: + if self.name_j == "Rigid": + self.name_j = "$name_j" + if self.name_e == "Rigid_e": + self.name_e = "$name_e" + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class RemoveRigidBody(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_remove" + bl_label = "Remove Rigid Body" + bl_description = "Deletes the currently selected Rigid Body" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.is_rigid_body_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + utils.selectAObject(obj) # ensure this is the only one object select + bpy.ops.object.delete(use_global=True) + if root: + utils.selectAObject(root) + return {"FINISHED"} + + +class RigidBodyBake(bpy.types.Operator): + bl_idname = "mmd_tools.ptcache_rigid_body_bake" + bl_label = "Bake" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context: bpy.types.Context): + with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): + bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True) + + return {"FINISHED"} + + +class RigidBodyDeleteBake(bpy.types.Operator): + bl_idname = "mmd_tools.ptcache_rigid_body_delete_bake" + bl_label = "Delete Bake" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context: bpy.types.Context): + with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): + bpy.ops.ptcache.free_bake("INVOKE_DEFAULT") + + return {"FINISHED"} + + +class AddJoint(bpy.types.Operator): + bl_idname = "mmd_tools.joint_add" + bl_label = "Add Joint" + bl_description = "Add Joint(s) to selected rigidbody objects" + bl_options = {"REGISTER", "UNDO", "PRESET", "INTERNAL"} + + use_bone_rotation: bpy.props.BoolProperty( + name="Use Bone Rotation", + description="Match joint orientation to bone orientation if enabled", + default=True, + ) + limit_linear_lower: bpy.props.FloatVectorProperty( + name="Limit Linear Lower", + description="Lower limit of translation", + subtype="XYZ", + size=3, + ) + limit_linear_upper: bpy.props.FloatVectorProperty( + name="Limit Linear Upper", + description="Upper limit of translation", + subtype="XYZ", + size=3, + ) + limit_angular_lower: bpy.props.FloatVectorProperty( + name="Limit Angular Lower", + description="Lower limit of rotation", + subtype="EULER", + size=3, + min=-math.pi * 2, + max=math.pi * 2, + default=[-math.pi / 4] * 3, + ) + limit_angular_upper: bpy.props.FloatVectorProperty( + name="Limit Angular Upper", + description="Upper limit of rotation", + subtype="EULER", + size=3, + min=-math.pi * 2, + max=math.pi * 2, + default=[math.pi / 4] * 3, + ) + spring_linear: bpy.props.FloatVectorProperty( + name="Spring(Linear)", + description="Spring constant of movement", + subtype="XYZ", + size=3, + min=0, + ) + spring_angular: bpy.props.FloatVectorProperty( + name="Spring(Angular)", + description="Spring constant of rotation", + subtype="XYZ", + size=3, + min=0, + ) + + def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]): + obj_seq = tuple(bone_map.keys()) + for rigid_a, bone_a in bone_map.items(): + for rigid_b, bone_b in bone_map.items(): + if bone_a and bone_b and bone_b.parent == bone_a: + obj_seq = () + yield (rigid_a, rigid_b) + if len(obj_seq) == 2: + if obj_seq[1].mmd_rigid.type == str(rigid_body.MODE_STATIC): + yield (obj_seq[1], obj_seq[0]) + 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): + loc: Optional[Vector] = None + rot = Euler((0.0, 0.0, 0.0)) + rigid_a, rigid_b = rigid_pair + bone_a = bone_map[rigid_a] + bone_b = bone_map[rigid_b] + if bone_a and bone_b: + if bone_a.parent == bone_b: + rigid_b, rigid_a = rigid_a, rigid_b + bone_b, bone_a = bone_a, bone_b + if bone_b.parent == bone_a: + loc = bone_b.head_local + if self.use_bone_rotation: + rot = bone_b.matrix_local.to_euler("YXZ") + rot.rotate_axis("X", math.pi / 2) + if loc is None: + loc = (rigid_a.location + rigid_b.location) / 2 + + name_j = rigid_b.mmd_rigid.name_j or rigid_b.name + name_e = rigid_b.mmd_rigid.name_e or rigid_b.name + + return FnRigidBody.setup_joint_object( + obj=FnRigidBody.new_joint_object(context, FnModel.ensure_joint_group_object(context, root_object), FnModel.get_empty_display_size(root_object)), + name=name_j, + name_e=name_e, + location=loc, + rotation=rot, + rigid_a=rigid_a, + rigid_b=rigid_b, + maximum_location=self.limit_linear_upper, + minimum_location=self.limit_linear_lower, + maximum_rotation=self.limit_angular_upper, + minimum_rotation=self.limit_angular_lower, + spring_linear=self.spring_linear, + spring_angular=self.spring_angular, + ) + + @classmethod + def poll(cls, context): + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + return False + + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return False + + return True + + def execute(self, context): + active_object = context.active_object + root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) + armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) + bones = cast(bpy.types.Armature, armature_object.data).bones + bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]] = {r: bones.get(r.mmd_rigid.bone, None) for r in FnModel.iterate_rigid_body_objects(root_object) if r.select_get()} + + if len(bone_map) < 2: + self.report({"ERROR"}, "Please select two or more mmd rigid objects") + return {"CANCELLED"} + + FnContext.select_single_object(context, root_object).select_set(False) + if context.scene.rigidbody_world is None: + bpy.ops.rigidbody.world_add() + + for pair in self.__enumerate_rigid_pair(bone_map): + joint = self.__add_joint(context, root_object, pair, bone_map) + joint.select_set(True) + + return {"FINISHED"} + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + +class RemoveJoint(bpy.types.Operator): + bl_idname = "mmd_tools.joint_remove" + bl_label = "Remove Joint" + bl_description = "Deletes the currently selected Joint" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.is_joint_object(context.active_object) + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + utils.selectAObject(obj) # ensure this is the only one object select + bpy.ops.object.delete(use_global=True) + if root: + utils.selectAObject(root) + return {"FINISHED"} + + +class UpdateRigidBodyWorld(bpy.types.Operator): + bl_idname = "mmd_tools.rigid_body_world_update" + bl_label = "Update Rigid Body World" + bl_description = "Update rigid body world and references of rigid body constraint according to current scene objects (experimental)" + bl_options = {"REGISTER", "UNDO"} + + @staticmethod + def __get_rigid_body_world_objects(): + rigid_body.setRigidBodyWorldEnabled(True) + rbw = bpy.context.scene.rigidbody_world + if not rbw.collection: + rbw.collection = bpy.data.collections.new("RigidBodyWorld") + rbw.collection.use_fake_user = True + if not rbw.constraints: + rbw.constraints = bpy.data.collections.new("RigidBodyConstraints") + rbw.constraints.use_fake_user = True + + bpy.context.scene.rigidbody_world.substeps_per_frame = 6 + bpy.context.scene.rigidbody_world.solver_iterations = 10 + + return rbw.collection.objects, rbw.constraints.objects + + def execute(self, context): + scene = context.scene + scene_objs = set(scene.objects) + scene_objs.union(o for x in scene.objects if x.instance_type == "COLLECTION" and x.instance_collection for o in x.instance_collection.objects) + + def _update_group(obj, group): + if obj in scene_objs: + if obj not in group.values(): + group.link(obj) + return True + elif obj in group.values(): + group.unlink(obj) + return False + + def _references(obj): + yield obj + if getattr(obj, "proxy", None): + yield from _references(obj.proxy) + if getattr(obj, "override_library", None): + yield from _references(obj.override_library.reference) + + need_rebuild_physics = scene.rigidbody_world is None or scene.rigidbody_world.collection is None or scene.rigidbody_world.constraints is None + rb_objs, rbc_objs = self.__get_rigid_body_world_objects() + objects = bpy.data.objects + table = {} + + # Perhaps due to a bug in Blender, + # when bpy.ops.rigidbody.world_remove(), + # Object.rigid_body are removed, + # but Object.rigid_body_constraint are retained. + # Therefore, it must be checked with Object.mmd_type. + for i in (x for x in objects if x.mmd_type == "RIGID_BODY"): + if not _update_group(i, rb_objs): + continue + + rb_map = table.setdefault(FnModel.find_root_object(i), {}) + if i in rb_map: # means rb_map[i] will replace i + rb_objs.unlink(i) + continue + for r in _references(i): + rb_map[r] = i + + # TODO Modify mmd_rigid to allow recovery of the remaining rigidbody parameters. + # mass, friction, restitution, linear_dumping, angular_dumping + + for i in (x for x in objects if x.rigid_body_constraint): + if not _update_group(i, rbc_objs): + continue + + rbc, root_object = i.rigid_body_constraint, FnModel.find_root_object(i) + rb_map = table.get(root_object, {}) + rbc.object1 = rb_map.get(rbc.object1, rbc.object1) + rbc.object2 = rb_map.get(rbc.object2, rbc.object2) + + if need_rebuild_physics: + for root_object in scene.objects: + if root_object.mmd_type != "ROOT": + continue + if not root_object.mmd_root.is_built: + continue + with FnContext.temp_override_active_layer_collection(context, root_object): + Model(root_object).build() + # After rebuild. First play. Will be crash! + # But saved it before. Reload after crash. The play can be work. + + return {"FINISHED"} diff --git a/core/mmd/operators/sdef.py b/core/mmd/operators/sdef.py new file mode 100644 index 0000000..e38badd --- /dev/null +++ b/core/mmd/operators/sdef.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Set + +import bpy +from bpy.types import Operator + +from ..core.model import FnModel +from ..core.sdef import FnSDEF + + +def _get_target_objects(context): + root_objects: Set[bpy.types.Object] = set() + selected_objects: Set[bpy.types.Object] = set() + for i in context.selected_objects: + if i.type == "MESH": + selected_objects.add(i) + continue + + root_object = FnModel.find_root_object(i) + if root_object is None: + continue + if root_object in root_objects: + continue + + root_objects.add(root_object) + + selected_objects |= set(FnModel.iterate_mesh_objects(root_object)) + return selected_objects, root_objects + + +class ResetSDEFCache(Operator): + bl_idname = "mmd_tools.sdef_cache_reset" + bl_label = "Reset MMD SDEF cache" + bl_description = "Reset MMD SDEF cache of selected objects and clean unused cache" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + target_meshes, _ = _get_target_objects(context) + for i in target_meshes: + FnSDEF.clear_cache(i) + FnSDEF.clear_cache(unused_only=True) + return {"FINISHED"} + + +class BindSDEF(Operator): + bl_idname = "mmd_tools.sdef_bind" + bl_label = "Bind SDEF Driver" + bl_description = "Bind MMD SDEF data of selected objects" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + mode: bpy.props.EnumProperty( + name="Mode", + description="Select mode", + items=[ + ("2", "Bulk", "Speed up with numpy (may be slower in some cases)", 2), + ("1", "Normal", "Normal mode", 1), + ("0", "- Auto -", "Select best mode by benchmark result", 0), + ], + default="0", + ) + use_skip: bpy.props.BoolProperty( + name="Skip", + description="Skip when the bones are not moving", + default=True, + ) + use_scale: bpy.props.BoolProperty( + name="Scale", + description="Support bone scaling (slow)", + default=False, + ) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + # TODO: Utility Functionalize + def execute(self, context): + target_meshes, root_objects = _get_target_objects(context) + + 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) + self.report({"INFO"}, f"Binded {count} of {len(target_meshes)} selected mesh(es)") + return {"FINISHED"} + + +class UnbindSDEF(Operator): + bl_idname = "mmd_tools.sdef_unbind" + bl_label = "Unbind SDEF Driver" + bl_description = "Unbind MMD SDEF data of selected objects" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + # TODO: Utility Functionalize + def execute(self, context): + target_meshes, root_objects = _get_target_objects(context) + for i in target_meshes: + FnSDEF.unbind(i) + + for r in root_objects: + r.mmd_root.use_sdef = False + + return {"FINISHED"} diff --git a/core/mmd/operators/translations.py b/core/mmd/operators/translations.py new file mode 100644 index 0000000..371427c --- /dev/null +++ b/core/mmd/operators/translations.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import TYPE_CHECKING, cast + +import bpy + +from ..core.model import FnModel, Model +from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations +from ..translations import DictionaryEnum + +if TYPE_CHECKING: + from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex + + +class TranslateMMDModel(bpy.types.Operator): + bl_idname = "mmd_tools.translate_mmd_model" + bl_label = "Translate a MMD Model" + bl_description = "Translate Japanese names of a MMD model" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + dictionary: bpy.props.EnumProperty( + name="Dictionary", + items=DictionaryEnum.get_dictionary_items, + description="Translate names from Japanese to English using selected dictionary", + ) + types: bpy.props.EnumProperty( + name="Types", + description="Select which parts will be translated", + options={"ENUM_FLAG"}, + items=[ + ("BONE", "Bones", "Bones", 1), + ("MORPH", "Morphs", "Morphs", 2), + ("MATERIAL", "Materials", "Materials", 4), + ("DISPLAY", "Display", "Display frames", 8), + ("PHYSICS", "Physics", "Rigidbodies and joints", 16), + ("INFO", "Information", "Model name and comments", 32), + ], + default={ + "BONE", + "MORPH", + "MATERIAL", + "DISPLAY", + "PHYSICS", + }, + ) + modes: bpy.props.EnumProperty( + name="Modes", + description="Select translation mode", + options={"ENUM_FLAG"}, + items=[ + ("MMD", "MMD Names", "Fill MMD English names", 1), + ("BLENDER", "Blender Names", "Translate blender names (experimental)", 2), + ], + default={"MMD"}, + ) + use_morph_prefix: bpy.props.BoolProperty( + name="Use Morph Prefix", + description="Add/remove prefix to English name of morph", + default=False, + ) + overwrite: bpy.props.BoolProperty( + name="Overwrite", + description="Overwrite a translated English name", + default=False, + ) + allow_fails: bpy.props.BoolProperty( + name="Allow Fails", + description="Allow incompletely translated names", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj in context.selected_objects and FnModel.find_root_object(obj) + + def invoke(self, context, event): + vm = context.window_manager + return vm.invoke_props_dialog(self) + + def execute(self, context): + try: + self.__translator = DictionaryEnum.get_translator(self.dictionary) + except Exception as e: + self.report({"ERROR"}, "Failed to load dictionary: %s" % e) + return {"CANCELLED"} + + obj = context.active_object + root = FnModel.find_root_object(obj) + rig = Model(root) + + if "MMD" in self.modes: + for i in self.types: + getattr(self, "translate_%s" % i.lower())(rig) + + if "BLENDER" in self.modes: + self.translate_blender_names(rig) + + translator = self.__translator + txt = translator.save_fails() + if translator.fails: + self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(translator.fails), txt.name)) + return {"FINISHED"} + + def translate(self, name_j, name_e): + if not self.overwrite and name_e and self.__translator.is_translated(name_e): + return name_e + if self.allow_fails: + name_e = None + return self.__translator.translate(name_j, name_e) + + def translate_blender_names(self, rig: Model): + if "BONE" in self.types: + for b in rig.armature().pose.bones: + rig.renameBone(b.name, self.translate(b.name, b.name)) + + if "MORPH" in self.types: + for i in (x for x in rig.meshes() if x.data.shape_keys): + for kb in i.data.shape_keys.key_blocks: + kb.name = self.translate(kb.name, kb.name) + + if "MATERIAL" in self.types: + for m in (x for x in rig.materials() if x): + m.name = self.translate(m.name, m.name) + + if "DISPLAY" in self.types: + g: bpy.types.BoneCollection + for g in cast(bpy.types.Armature, rig.armature().data).collections: + g.name = self.translate(g.name, g.name) + + if "PHYSICS" in self.types: + for i in rig.rigidBodies(): + i.name = self.translate(i.name, i.name) + + for i in rig.joints(): + i.name = self.translate(i.name, i.name) + + if "INFO" in self.types: + objects = [rig.rootObject(), rig.armature()] + objects.extend(rig.meshes()) + for i in objects: + i.name = self.translate(i.name, i.name) + + def translate_info(self, rig): + mmd_root = rig.rootObject().mmd_root + mmd_root.name_e = self.translate(mmd_root.name, mmd_root.name_e) + + comment_text = bpy.data.texts.get(mmd_root.comment_text, None) + comment_e_text = bpy.data.texts.get(mmd_root.comment_e_text, None) + if comment_text and comment_e_text: + comment_e = self.translate(comment_text.as_string(), comment_e_text.as_string()) + comment_e_text.from_string(comment_e) + + def translate_bone(self, rig): + bones = rig.armature().pose.bones + for b in bones: + if b.is_mmd_shadow_bone: + continue + b.mmd_bone.name_e = self.translate(b.mmd_bone.name_j, b.mmd_bone.name_e) + + def translate_morph(self, rig): + mmd_root = rig.rootObject().mmd_root + attr_list = ("group", "vertex", "bone", "uv", "material") + prefix_list = ("G_", "", "B_", "UV_", "M_") + for attr, prefix in zip(attr_list, prefix_list): + for m in getattr(mmd_root, attr + "_morphs", []): + m.name_e = self.translate(m.name, m.name_e) + if not prefix: + continue + if self.use_morph_prefix: + if not m.name_e.startswith(prefix): + m.name_e = prefix + m.name_e + elif m.name_e.startswith(prefix): + m.name_e = m.name_e[len(prefix) :] + + def translate_material(self, rig): + for m in rig.materials(): + if m is None: + continue + m.mmd_material.name_e = self.translate(m.mmd_material.name_j, m.mmd_material.name_e) + + def translate_display(self, rig): + mmd_root = rig.rootObject().mmd_root + for f in mmd_root.display_item_frames: + f.name_e = self.translate(f.name, f.name_e) + + def translate_physics(self, rig): + for i in rig.rigidBodies(): + i.mmd_rigid.name_e = self.translate(i.mmd_rigid.name_j, i.mmd_rigid.name_e) + + for i in rig.joints(): + i.mmd_joint.name_e = self.translate(i.mmd_joint.name_j, i.mmd_joint.name_e) + + +DEFAULT_SHOW_ROW_COUNT = 20 + + +class MMD_TOOLS_UL_MMDTranslationElementIndex(bpy.types.UIList): + def draw_item(self, context, layout: bpy.types.UILayout, data, mmd_translation_element_index: "MMDTranslationElementIndex", icon, active_data, active_propname, index: int): + mmd_translation_element: "MMDTranslationElement" = data.translation_elements[mmd_translation_element_index.value] + MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(layout, mmd_translation_element, index) + + +class RestoreMMDDataReferenceOperator(bpy.types.Operator): + bl_idname = "mmd_tools.restore_mmd_translation_element_name" + bl_label = "Restore this Name" + bl_options = {"INTERNAL"} + + index: bpy.props.IntProperty() + prop_name: bpy.props.StringProperty() + restore_value: bpy.props.StringProperty() + + def execute(self, context: bpy.types.Context): + root_object = FnModel.find_root_object(context.object) + mmd_translation_element_index = root_object.mmd_root.translation.filtered_translation_element_indices[self.index].value + mmd_translation_element = root_object.mmd_root.translation.translation_elements[mmd_translation_element_index] + setattr(mmd_translation_element, self.prop_name, self.restore_value) + + return {"FINISHED"} + + +class GlobalTranslationPopup(bpy.types.Operator): + bl_idname = "mmd_tools.global_translation_popup" + bl_label = "Global Translation Popup" + bl_options = {"INTERNAL", "UNDO"} + + @classmethod + def poll(cls, context): + return FnModel.find_root_object(context.object) is not None + + def draw(self, _context): + layout = self.layout + mmd_translation = self._mmd_translation + + col = layout.column(align=True) + col.label(text="Filter", icon="FILTER") + row = col.row() + row.prop(mmd_translation, "filter_types") + + group = row.row(align=True, heading="is Blank:") + group.alignment = "RIGHT" + group.prop(mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese") + group.prop(mmd_translation, "filter_english_blank", toggle=True, text="English") + + group = row.row(align=True) + group.prop(mmd_translation, "filter_restorable", toggle=True, icon="FILE_REFRESH", icon_only=True) + group.prop(mmd_translation, "filter_selected", toggle=True, icon="RESTRICT_SELECT_OFF", icon_only=True) + group.prop(mmd_translation, "filter_visible", toggle=True, icon="HIDE_OFF", icon_only=True) + + col = layout.column(align=True) + box = col.box().column(align=True) + row = box.row(align=True) + row.label(text="Select the target column for Batch Operations:", icon="TRACKER") + row = box.row(align=True) + row.label(text="", icon="BLANK1") + row.prop(mmd_translation, "batch_operation_target", expand=True) + row.label(text="", icon="RESTRICT_SELECT_OFF") + row.label(text="", icon="HIDE_OFF") + + if len(mmd_translation.filtered_translation_element_indices) > DEFAULT_SHOW_ROW_COUNT: + row.label(text="", icon="BLANK1") + + col.template_list( + "MMD_TOOLS_UL_MMDTranslationElementIndex", + "", + mmd_translation, + "filtered_translation_element_indices", + mmd_translation, + "filtered_translation_element_indices_active_index", + rows=DEFAULT_SHOW_ROW_COUNT, + ) + + box = layout.box().column(align=True) + box.label(text="Batch Operation:", icon="MODIFIER") + box.prop(mmd_translation, "batch_operation_script", text="", icon="SCRIPT") + + box.separator() + row = box.row() + row.prop(mmd_translation, "batch_operation_script_preset", text="Preset", icon="CON_TRANSFORM_CACHE") + row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute") + + box.separator() + translation_box = box.box().column(align=True) + translation_box.label(text="Dictionaries:", icon="HELP") + row = translation_box.row() + row.prop(mmd_translation, "dictionary", text="to_english") + # row.operator(ExecuteTranslationScriptOperator.bl_idname, text='Write to .csv') + + translation_box.separator() + row = translation_box.row() + row.prop(mmd_translation, "dictionary", text="replace") + + def invoke(self, context: bpy.types.Context, _event): + root_object = FnModel.find_root_object(context.object) + if root_object is None: + return {"CANCELLED"} + + mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + self._mmd_translation = mmd_translation + FnTranslations.clear_data(mmd_translation) + FnTranslations.collect_data(mmd_translation) + FnTranslations.update_query(mmd_translation) + + return context.window_manager.invoke_props_dialog(self, width=800) + + def execute(self, context): + root_object = FnModel.find_root_object(context.object) + if root_object is None: + return {"CANCELLED"} + + FnTranslations.apply_translations(root_object) + FnTranslations.clear_data(root_object.mmd_root.translation) + + return {"FINISHED"} + + +class ExecuteTranslationBatchOperator(bpy.types.Operator): + bl_idname = "mmd_tools.execute_translation_batch" + bl_label = "Execute Translation Batch" + bl_options = {"INTERNAL"} + + def execute(self, context: bpy.types.Context): + root = FnModel.find_root_object(context.object) + if root is None: + return {"CANCELLED"} + + fails, text = FnTranslations.execute_translation_batch(root) + if fails: + self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(fails), text.name)) + + return {"FINISHED"} diff --git a/core/mmd/operators/view.py b/core/mmd/operators/view.py new file mode 100644 index 0000000..0072312 --- /dev/null +++ b/core/mmd/operators/view.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import re + +from bpy.types import Operator +from mathutils import Matrix + + +class _SetShadingBase: + bl_options = {"REGISTER", "UNDO"} + + @staticmethod + def _get_view3d_spaces(context): + if getattr(context.area, "type", None) == "VIEW_3D": + return (context.area.spaces[0],) + return (area.spaces[0] for area in getattr(context.screen, "areas", ()) if area.type == "VIEW_3D") + + @staticmethod + def _reset_color_management(context, use_display_device=True): + try: + context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device] + except TypeError: + pass + + @staticmethod + def _reset_material_shading(context, use_shadeless=False): + for i in (x for x in context.scene.objects if x.type == "MESH" and x.mmd_type == "NONE"): + for s in i.material_slots: + if s.material is None: + continue + s.material.use_nodes = False + s.material.use_shadeless = use_shadeless + + def execute(self, context): + context.scene.render.engine = "BLENDER_EEVEE_NEXT" + + shading_mode = getattr(self, "_shading_mode", None) + for space in self._get_view3d_spaces(context): + shading = space.shading + shading.type = "SOLID" + shading.light = "FLAT" if shading_mode == "SHADELESS" else "STUDIO" + shading.color_type = "TEXTURE" if shading_mode else "MATERIAL" + shading.show_object_outline = False + shading.show_backface_culling = False + 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" + + _shading_mode = "GLSL" + + +class SetShadelessGLSLShading(Operator, _SetShadingBase): + bl_idname = "mmd_tools.set_shadeless_glsl_shading" + bl_label = "Shadeless GLSL View" + bl_description = "Use only toon shading" + + _shading_mode = "SHADELESS" + + +class ResetShading(Operator, _SetShadingBase): + bl_idname = "mmd_tools.reset_shading" + bl_label = "Reset View" + bl_description = "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"} + + # https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html + __LR_REGEX = [ + {"re": re.compile(r"^(.+)(RIGHT|LEFT)(\.\d+)?$", re.IGNORECASE), "lr": 1}, + {"re": re.compile(r"^(.+)([\.\- _])(L|R)(\.\d+)?$", re.IGNORECASE), "lr": 2}, + {"re": re.compile(r"^(LEFT|RIGHT)(.+)$", re.IGNORECASE), "lr": 0}, + {"re": re.compile(r"^(L|R)([\.\- _])(.+)$", re.IGNORECASE), "lr": 0}, + {"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1}, + {"re": re.compile(r"^(左|右)(.+)$"), "lr": 0}, + ] + __LR_MAP = { + "RIGHT": "LEFT", + "Right": "Left", + "right": "left", + "LEFT": "RIGHT", + "Left": "Right", + "left": "right", + "L": "R", + "l": "r", + "R": "L", + "r": "l", + "左": "右", + "右": "左", + } + + @classmethod + def flip_name(cls, name): + for regex in cls.__LR_REGEX: + match = regex["re"].match(name) + if match: + groups = match.groups() + lr = groups[regex["lr"]] + if lr in cls.__LR_MAP: + flip_lr = cls.__LR_MAP[lr] + name = "" + for i, s in enumerate(groups): + if i == regex["lr"]: + name += flip_lr + elif s: + name += s + return name + return "" + + @staticmethod + def __cmul(vec1, vec2): + return type(vec1)([x * y for x, y in zip(vec1, vec2)]) + + @staticmethod + def __matrix_compose(loc, rot, scale): + return (Matrix.Translation(loc) @ rot.to_matrix().to_4x4()) @ Matrix([(scale[0], 0, 0, 0), (0, scale[1], 0, 0), (0, 0, scale[2], 0), (0, 0, 0, 1)]) + + @classmethod + def __flip_pose(cls, matrix_basis, bone_src, bone_dest): + from mathutils import Quaternion + + 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() + loc = cls.__cmul(mi @ loc, (-1, 1, 1)) + rot = cls.__cmul(Quaternion(mi @ rot.axis, rot.angle).normalized(), (1, 1, -1, -1)) + bone_dest.matrix_basis = cls.__matrix_compose(m @ loc, Quaternion(m @ rot.axis, rot.angle).normalized(), scale) + + @classmethod + def poll(cls, context): + return context.active_object and context.active_object.type == "ARMATURE" and context.active_object.mode == "POSE" + + def execute(self, context): + pose_bones = context.active_object.pose.bones + for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]: + self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b)) + return {"FINISHED"} diff --git a/core/mmd/properties/__init__.py b/core/mmd/properties/__init__.py new file mode 100644 index 0000000..9f5926d --- /dev/null +++ b/core/mmd/properties/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + + +def patch_library_overridable(property: "bpy.props._PropertyDeferred") -> "bpy.props._PropertyDeferred": + """Apply recursively for each mmd_tools property class annotations. + Args: + property: The property to be patched. + + Returns: + The patched property. + """ + property.keywords.setdefault("override", set()).add("LIBRARY_OVERRIDABLE") + + if property.function.__name__ not in {"PointerProperty", "CollectionProperty"}: + return property + + property_type = property.keywords["type"] + # The __annotations__ cannot be inherited. Manually search for base classes. + for inherited_type in (property_type, *property_type.__bases__): + if not inherited_type.__module__.startswith("mmd_tools.properties"): + continue + for annotation in inherited_type.__annotations__.values(): + if not isinstance(annotation, bpy.props._PropertyDeferred): + continue + patch_library_overridable(annotation) + + return property diff --git a/core/mmd/properties/material.py b/core/mmd/properties/material.py new file mode 100644 index 0000000..d3df3a3 --- /dev/null +++ b/core/mmd/properties/material.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from .. import utils +from ..core import material +from ..core.material import FnMaterial +from ..core.model import FnModel +from . import patch_library_overridable + + +def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_ambient_color() + + +def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_diffuse_color() + + +def _mmd_material_update_alpha(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_alpha() + + +def _mmd_material_update_specular_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_specular_color() + + +def _mmd_material_update_shininess(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_shininess() + + +def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_is_double_sided() + + +def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context): + FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object) + + +def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_toon_texture() + + +def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_drop_shadow() + + +def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_self_shadow_map() + + +def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_self_shadow() + + +def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_enabled_toon_edge() + + +def _mmd_material_update_edge_color(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_edge_color() + + +def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context): + FnMaterial(prop.id_data).update_edge_weight() + + +def _mmd_material_get_name_j(prop: "MMDMaterial"): + return prop.get("name_j", "") + + +def _mmd_material_set_name_j(prop: "MMDMaterial", value: str): + prop_value = value + if prop_value and prop_value != prop.get("name_j"): + root = FnModel.find_root_object(bpy.context.active_object) + if root is None: + prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in bpy.data.materials}) + else: + prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in FnModel.iterate_materials(root)}) + + prop["name_j"] = prop_value + + +# =========================================== +# Property classes +# =========================================== + + +class MMDMaterial(bpy.types.PropertyGroup): + """マテリアル""" + + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + set=_mmd_material_set_name_j, + get=_mmd_material_get_name_j, + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + material_id: bpy.props.IntProperty( + name="Material ID", + description="Unique ID for the reference of material morph", + default=-1, + min=-1, + ) + + ambient_color: bpy.props.FloatVectorProperty( + name="Ambient Color", + description="Ambient color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.4, 0.4, 0.4], + update=_mmd_material_update_ambient_color, + ) + + diffuse_color: bpy.props.FloatVectorProperty( + name="Diffuse Color", + description="Diffuse color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.8, 0.8, 0.8], + update=_mmd_material_update_diffuse_color, + ) + + alpha: bpy.props.FloatProperty( + name="Alpha", + description="Alpha transparency", + min=0, + max=1, + precision=3, + step=0.1, + default=1.0, + update=_mmd_material_update_alpha, + ) + + specular_color: bpy.props.FloatVectorProperty( + name="Specular Color", + description="Specular color", + subtype="COLOR", + size=3, + min=0, + max=1, + precision=3, + step=0.1, + default=[0.625, 0.625, 0.625], + update=_mmd_material_update_specular_color, + ) + + shininess: bpy.props.FloatProperty( + name="Reflect", + description="Sharpness of reflected highlights", + min=0, + soft_max=512, + step=100.0, + default=50.0, + update=_mmd_material_update_shininess, + ) + + is_double_sided: bpy.props.BoolProperty( + name="Double Sided", + description="Both sides of mesh should be rendered", + default=False, + update=_mmd_material_update_is_double_sided, + ) + + enabled_drop_shadow: bpy.props.BoolProperty( + name="Ground Shadow", + description="Display ground shadow", + default=True, + update=_mmd_material_update_enabled_drop_shadow, + ) + + enabled_self_shadow_map: bpy.props.BoolProperty( + name="Self Shadow Map", + description="Object can become shadowed by other objects", + default=True, + update=_mmd_material_update_enabled_self_shadow_map, + ) + + enabled_self_shadow: bpy.props.BoolProperty( + name="Self Shadow", + description="Object can cast shadows", + default=True, + update=_mmd_material_update_enabled_self_shadow, + ) + + enabled_toon_edge: bpy.props.BoolProperty( + name="Toon Edge", + description="Use toon edge", + default=False, + update=_mmd_material_update_enabled_toon_edge, + ) + + edge_color: bpy.props.FloatVectorProperty( + name="Edge Color", + description="Toon edge color", + subtype="COLOR", + size=4, + min=0, + max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_mmd_material_update_edge_color, + ) + + edge_weight: bpy.props.FloatProperty( + name="Edge Weight", + description="Toon edge size", + min=0, + max=100, + soft_max=2, + step=1.0, + default=1.0, + update=_mmd_material_update_edge_weight, + ) + + sphere_texture_type: bpy.props.EnumProperty( + name="Sphere Map Type", + description="Choose sphere texture blend type", + items=[ + (str(material.SPHERE_MODE_OFF), "Off", "", 1), + (str(material.SPHERE_MODE_MULT), "Multiply", "", 2), + (str(material.SPHERE_MODE_ADD), "Add", "", 3), + (str(material.SPHERE_MODE_SUBTEX), "SubTexture", "", 4), + ], + update=_mmd_material_update_sphere_texture_type, + ) + + is_shared_toon_texture: bpy.props.BoolProperty( + name="Use Shared Toon Texture", + description="Use shared toon texture or custom toon texture", + default=False, + update=_mmd_material_update_toon_texture, + ) + + toon_texture: bpy.props.StringProperty( + name="Toon Texture", + subtype="FILE_PATH", + description="The file path of custom toon texture", + default="", + update=_mmd_material_update_toon_texture, + ) + + shared_toon_texture: bpy.props.IntProperty( + name="Shared Toon Texture", + description="Shared toon texture id (toon01.bmp ~ toon10.bmp)", + default=0, + min=0, + max=9, + update=_mmd_material_update_toon_texture, + ) + + comment: bpy.props.StringProperty( + name="Comment", + description="Comment", + ) + + def is_id_unique(self): + return self.material_id < 0 or not next((m for m in bpy.data.materials if m.mmd_material != self and m.mmd_material.material_id == self.material_id), None) + + @staticmethod + def register(): + bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial)) + + @staticmethod + def unregister(): + del bpy.types.Material.mmd_material diff --git a/core/mmd/properties/morph.py b/core/mmd/properties/morph.py new file mode 100644 index 0000000..ba94350 --- /dev/null +++ b/core/mmd/properties/morph.py @@ -0,0 +1,488 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import bpy + +from .. import utils +from ..core.bone import FnBone +from ..core.material import FnMaterial +from ..core.model import FnModel, Model +from ..core.morph import FnMorph + + +def _morph_base_get_name(prop: "_MorphBase") -> str: + return prop.get("name", "") + + +def _morph_base_set_name(prop: "_MorphBase", value: str): + 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} + value = utils.unique_name(value, used_names) + if prop_name is not None: + if morph_type == "vertex_morphs": + kb_list = {} + for mesh in FnModel.iterate_mesh_objects(prop.id_data): + for kb in getattr(mesh.data.shape_keys, "key_blocks", ()): + kb_list.setdefault(kb.name, []).append(kb) + + if prop_name in kb_list: + value = utils.unique_name(value, used_names | kb_list.keys()) + for kb in kb_list[prop_name]: + kb.name = value + + elif morph_type == "uv_morphs": + vg_list = {} + for mesh in FnModel.iterate_mesh_objects(prop.id_data): + for vg, n, x in FnMorph.get_uv_morph_vertex_groups(mesh): + vg_list.setdefault(n, []).append(vg) + + if prop_name in vg_list: + value = utils.unique_name(value, used_names | vg_list.keys()) + for vg in vg_list[prop_name]: + vg.name = vg.name.replace(prop_name, value) + + if 1: # morph_type != 'group_morphs': + for m in mmd_root.group_morphs: + for d in m.data: + if d.name == prop_name and d.morph_type == morph_type: + d.name = value + + frame_facial = mmd_root.display_item_frames.get("表情") + for item in getattr(frame_facial, "data", []): + if item.name == prop_name and item.morph_type == morph_type: + item.name = value + break + + obj = Model(prop.id_data).morph_slider.placeholder() + if obj and value not in obj.data.shape_keys.key_blocks: + kb = obj.data.shape_keys.key_blocks.get(prop_name, None) + if kb: + kb.name = value + + prop["name"] = value + + +class _MorphBase: + name: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + set=_morph_base_set_name, + get=_morph_base_get_name, + ) + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + category: bpy.props.EnumProperty( + name="Category", + description="Select category", + items=[ + ("SYSTEM", "Hidden", "", 0), + ("EYEBROW", "Eye Brow", "", 1), + ("EYE", "Eye", "", 2), + ("MOUTH", "Mouth", "", 3), + ("OTHER", "Other", "", 4), + ], + default="OTHER", + ) + + +def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: + bone_id = prop.get("bone_id", -1) + if bone_id < 0: + return "" + root_object = prop.id_data + armature_object = FnModel.find_armature_object(root_object) + if armature_object is None: + return "" + pose_bone = FnBone.find_pose_bone_by_bone_id(armature_object, bone_id) + if pose_bone is None: + return "" + return pose_bone.name + + +def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str): + root = prop.id_data + arm = FnModel.find_armature_object(root) + + # Load the library_override file. This function is triggered when loading, but the arm obj cannot be found. + # The arm obj is exist, but the relative relationship has not yet been established. + if arm is None: + return + + if value not in arm.pose.bones.keys(): + prop["bone_id"] = -1 + return + pose_bone = arm.pose.bones[value] + prop["bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) + + +def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context): + if not prop.name.startswith("mmd_bind"): + return + arm = FnModel(prop.id_data).morph_slider.dummy_armature + if arm: + bone = arm.pose.bones.get(prop.name, None) + if bone: + bone.location = prop.location + bone.rotation_quaternion = prop.rotation.__class__(*prop.rotation.to_axis_angle()) # Fix for consistency + + +class BoneMorphData(bpy.types.PropertyGroup): + """ """ + + bone: bpy.props.StringProperty( + name="Bone", + description="Target bone", + set=_bone_morph_data_set_bone, + get=_bone_morph_data_get_bone, + ) + + bone_id: bpy.props.IntProperty( + name="Bone ID", + ) + + location: bpy.props.FloatVectorProperty( + name="Location", + description="Location", + subtype="TRANSLATION", + size=3, + default=[0, 0, 0], + update=_bone_morph_data_update_location_or_rotation, + ) + + rotation: bpy.props.FloatVectorProperty( + name="Rotation", + description="Rotation in quaternions", + subtype="QUATERNION", + size=4, + default=[1, 0, 0, 0], + update=_bone_morph_data_update_location_or_rotation, + ) + + +class BoneMorph(_MorphBase, bpy.types.PropertyGroup): + """Bone Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=BoneMorphData, + ) + active_data: bpy.props.IntProperty( + name="Active Bone Data", + min=0, + default=0, + ) + + +def _material_morph_data_get_material(prop: "MaterialMorphData"): + 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): + if value not in bpy.data.materials: + prop["material_data"] = None + prop["material_id"] = -1 + else: + mat = bpy.data.materials[value] + fnMat = FnMaterial(mat) + prop["material_data"] = mat + prop["material_id"] = fnMat.material_id + + +def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str): + mesh = FnModel.find_mesh_object_by_name(prop.id_data, value) + if mesh is not None: + prop["related_mesh_data"] = mesh.data + else: + prop["related_mesh_data"] = None + + +def _material_morph_data_get_related_mesh(prop): + 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): + if not prop.name.startswith("mmd_bind"): + return + from ..core.shader import _MaterialMorph + + mat = prop["material_data"] + if mat is not None: + _MaterialMorph.update_morph_inputs(mat, prop) + else: + for mat in FnModel(prop.id_data).materials(): + _MaterialMorph.update_morph_inputs(mat, prop) + + +class MaterialMorphData(bpy.types.PropertyGroup): + """ """ + + related_mesh: bpy.props.StringProperty( + name="Related Mesh", + description="Stores a reference to the mesh where this morph data belongs to", + set=_material_morph_data_set_related_mesh, + get=_material_morph_data_get_related_mesh, + ) + + related_mesh_data: bpy.props.PointerProperty( + name="Related Mesh Data", + type=bpy.types.Mesh, + ) + + offset_type: bpy.props.EnumProperty(name="Offset Type", description="Select offset type", items=[("MULT", "Multiply", "", 0), ("ADD", "Add", "", 1)], default="ADD") + + material: bpy.props.StringProperty( + name="Material", + description="Target material", + get=_material_morph_data_get_material, + set=_material_morph_data_set_material, + ) + + material_id: bpy.props.IntProperty( + name="Material ID", + default=-1, + ) + + material_data: bpy.props.PointerProperty( + name="Material Data", + type=bpy.types.Material, + ) + + diffuse_color: bpy.props.FloatVectorProperty( + name="Diffuse Color", + description="Diffuse color", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + specular_color: bpy.props.FloatVectorProperty( + name="Specular Color", + description="Specular color", + subtype="COLOR", + size=3, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0], + update=_material_morph_data_update_modifiable_values, + ) + + shininess: bpy.props.FloatProperty( + name="Reflect", + description="Reflect", + soft_min=0, + soft_max=500, + step=100.0, + default=0.0, + update=_material_morph_data_update_modifiable_values, + ) + + ambient_color: bpy.props.FloatVectorProperty( + name="Ambient Color", + description="Ambient color", + subtype="COLOR", + size=3, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0], + update=_material_morph_data_update_modifiable_values, + ) + + edge_color: bpy.props.FloatVectorProperty( + name="Edge Color", + description="Edge color", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + edge_weight: bpy.props.FloatProperty( + name="Edge Weight", + description="Edge weight", + soft_min=0, + soft_max=2, + step=0.1, + default=0, + update=_material_morph_data_update_modifiable_values, + ) + + texture_factor: bpy.props.FloatVectorProperty( + name="Texture factor", + description="Texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + sphere_texture_factor: bpy.props.FloatVectorProperty( + name="Sphere Texture factor", + description="Sphere texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + toon_texture_factor: bpy.props.FloatVectorProperty( + name="Toon Texture factor", + description="Toon texture factor", + subtype="COLOR", + size=4, + soft_min=0, + soft_max=1, + precision=3, + step=0.1, + default=[0, 0, 0, 1], + update=_material_morph_data_update_modifiable_values, + ) + + +class MaterialMorph(_MorphBase, bpy.types.PropertyGroup): + """Material Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=MaterialMorphData, + ) + active_data: bpy.props.IntProperty( + name="Active Material Data", + min=0, + default=0, + ) + + +class UVMorphOffset(bpy.types.PropertyGroup): + """UV Morph Offset""" + + index: bpy.props.IntProperty( + name="Vertex Index", + description="Vertex index", + min=0, + default=0, + ) + offset: bpy.props.FloatVectorProperty( + name="UV Offset", + description="UV offset", + size=4, + # min=-1, + # max=1, + # precision=3, + step=0.1, + default=[0, 0, 0, 0], + ) + + +class UVMorph(_MorphBase, bpy.types.PropertyGroup): + """UV Morph""" + + uv_index: bpy.props.IntProperty( + name="UV Index", + description="UV index (UV, UV1 ~ UV4)", + min=0, + max=4, + default=0, + ) + data_type: bpy.props.EnumProperty( + name="Data Type", + description="Select data type", + items=[ + ("DATA", "Data", "Store offset data in root object (deprecated)", 0), + ("VERTEX_GROUP", "Vertex Group", "Store offset data in vertex groups", 1), + ], + default="DATA", + ) + data: bpy.props.CollectionProperty( + name="Morph Data", + type=UVMorphOffset, + ) + active_data: bpy.props.IntProperty( + name="Active UV Data", + min=0, + default=0, + ) + vertex_group_scale: bpy.props.FloatProperty( + name="Vertex Group Scale", + description='The value scale of "Vertex Group" data type', + precision=3, + step=0.1, + default=1, + ) + + +class GroupMorphOffset(bpy.types.PropertyGroup): + """Group Morph Offset""" + + morph_type: bpy.props.EnumProperty( + name="Morph Type", + description="Select morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + factor: bpy.props.FloatProperty(name="Factor", description="Factor", soft_min=0, soft_max=1, precision=3, step=0.1, default=0) + + +class GroupMorph(_MorphBase, bpy.types.PropertyGroup): + """Group Morph""" + + data: bpy.props.CollectionProperty( + name="Morph Data", + type=GroupMorphOffset, + ) + active_data: bpy.props.IntProperty( + name="Active Group Data", + min=0, + default=0, + ) + + +class VertexMorph(_MorphBase, bpy.types.PropertyGroup): + """Vertex Morph""" diff --git a/core/mmd/properties/pose_bone.py b/core/mmd/properties/pose_bone.py new file mode 100644 index 0000000..3584c42 --- /dev/null +++ b/core/mmd/properties/pose_bone.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import cast +import bpy + +from ..core.bone import FnBone +from . import patch_library_overridable + + +def _mmd_bone_update_additional_transform(prop: "MMDBone", context: bpy.types.Context): + 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(): + FnBone.apply_additional_transformation(prop.id_data) + + +def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: bpy.types.Context): + pose_bone = context.active_pose_bone + if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer(): + FnBone.update_additional_transform_influence(pose_bone) + else: + prop["is_additional_transform_dirty"] = True + + +def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"): + arm = prop.id_data + bone_id = prop.get("additional_transform_bone_id", -1) + if bone_id < 0: + return "" + pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id) + if pose_bone is None: + return "" + return pose_bone.name + + +def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str): + arm = prop.id_data + prop["is_additional_transform_dirty"] = True + if value not in arm.pose.bones.keys(): + prop["additional_transform_bone_id"] = -1 + return + pose_bone = arm.pose.bones[value] + prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) + + +class MMDBone(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + bone_id: bpy.props.IntProperty( + name="Bone ID", + description="Unique ID for the reference of bone morph and rotate+/move+", + default=-1, + min=-1, + ) + + transform_order: bpy.props.IntProperty( + name="Transform Order", + description="Deformation tier", + min=0, + max=100, + soft_max=7, + ) + + is_controllable: bpy.props.BoolProperty( + name="Controllable", + description="Is controllable", + default=True, + ) + + transform_after_dynamics: bpy.props.BoolProperty( + name="After Dynamics", + description="After physics", + default=False, + ) + + enabled_fixed_axis: bpy.props.BoolProperty( + name="Fixed Axis", + description="Use fixed axis", + default=False, + ) + + fixed_axis: bpy.props.FloatVectorProperty( + name="Fixed Axis", + description="Fixed axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, # 0.1 / 100 + default=[0, 0, 0], + ) + + enabled_local_axes: bpy.props.BoolProperty( + name="Local Axes", + description="Use local axes", + default=False, + ) + + local_axis_x: bpy.props.FloatVectorProperty( + name="Local X-Axis", + description="Local x-axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, + default=[1, 0, 0], + ) + + local_axis_z: bpy.props.FloatVectorProperty( + name="Local Z-Axis", + description="Local z-axis", + subtype="XYZ", + size=3, + precision=3, + step=0.1, + default=[0, 0, 1], + ) + + is_tip: bpy.props.BoolProperty( + name="Tip Bone", + description="Is zero length bone", + default=False, + ) + + ik_rotation_constraint: bpy.props.FloatProperty( + name="IK Rotation Constraint", + description="The unit angle of IK", + subtype="ANGLE", + soft_min=0, + soft_max=4, + default=1, + ) + + has_additional_rotation: bpy.props.BoolProperty( + name="Additional Rotation", + description="Additional rotation", + default=False, + update=_mmd_bone_update_additional_transform, + ) + + has_additional_location: bpy.props.BoolProperty( + name="Additional Location", + description="Additional location", + default=False, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_bone: bpy.props.StringProperty( + name="Additional Transform Bone", + description="Additional transform bone", + set=_mmd_bone_set_additional_transform_bone, + get=_mmd_bone_get_additional_transform_bone, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_bone_id: bpy.props.IntProperty( + name="Additional Transform Bone ID", + default=-1, + update=_mmd_bone_update_additional_transform, + ) + + additional_transform_influence: bpy.props.FloatProperty( + name="Additional Transform Influence", + description="Additional transform influence", + default=1, + soft_min=-1, + soft_max=1, + update=_mmd_bone_update_additional_transform_influence, + ) + + is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True) + + def is_id_unique(self): + return self.bone_id < 0 or not next((b for b in self.id_data.pose.bones if b.mmd_bone != self and b.mmd_bone.bone_id == self.bone_id), None) + + @staticmethod + def register(): + 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")) + bpy.types.PoseBone.mmd_ik_toggle = patch_library_overridable( + bpy.props.BoolProperty( + name="MMD IK Toggle", + description="MMD IK toggle is used to import/export animation of IK on-off", + update=_pose_bone_update_mmd_ik_toggle, + default=True, + ) + ) + + @staticmethod + def unregister(): + 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): + v = prop.mmd_ik_toggle + armature_object = cast(bpy.types.Object, prop.id_data) + for b in armature_object.pose.bones: + for c in b.constraints: + if c.type == "IK" and c.subtarget == prop.name: + # logging.debug(' %s %s', b.name, c.name) + c.influence = v + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + c.influence = v diff --git a/core/mmd/properties/rigid_body.py b/core/mmd/properties/rigid_body.py new file mode 100644 index 0000000..3941657 --- /dev/null +++ b/core/mmd/properties/rigid_body.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +"""Properties for rigid bodies and joints""" + +import bpy + +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 + + +def _updateCollisionGroup(prop, _context): + obj = prop.id_data + materials = obj.data.materials + if len(materials) == 0: + materials.append(RigidBodyMaterial.getMaterial(prop.collision_group_number)) + else: + obj.material_slots[0].material = RigidBodyMaterial.getMaterial(prop.collision_group_number) + + +def _updateType(prop, _context): + obj = 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 + + if len(obj.data.vertices) > 0: + size = prop.size + prop.size = size # update mesh + + rb = obj.rigid_body + if rb: + rb.collision_shape = prop.shape + + +def _get_bone(prop): + obj = prop.id_data + relation = obj.constraints.get("mmd_tools_rigid_parent", None) + if relation: + arm = relation.target + bone_name = relation.subtarget + if arm is not None and bone_name in arm.data.bones: + return bone_name + return prop.get("bone", "") + + +def _set_bone(prop, value): + bone_name = value + obj = prop.id_data + relation = obj.constraints.get("mmd_tools_rigid_parent", None) + if relation is None: + relation = obj.constraints.new("CHILD_OF") + relation.name = "mmd_tools_rigid_parent" + relation.mute = True + + arm = relation.target + if arm is None: + root = FnModel.find_root_object(obj) + if root: + arm = relation.target = FnModel.find_armature_object(root) + + if arm is not None and bone_name in arm.data.bones: + relation.subtarget = bone_name + else: + relation.subtarget = bone_name = "" + + prop["bone"] = bone_name + + +def _get_size(prop): + if prop.id_data.mmd_type != "RIGID_BODY": + return (0, 0, 0) + return FnRigidBody.get_rigid_body_size(prop.id_data) + + +def _set_size(prop, value): + obj = prop.id_data + assert obj.mode == "OBJECT" # not support other mode yet + shape = prop.shape + + mesh = obj.data + rb = obj.rigid_body + + if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape: + if shape == "SPHERE": + bpyutils.makeSphere( + radius=value[0], + target_object=obj, + ) + elif shape == "BOX": + bpyutils.makeBox( + size=value, + target_object=obj, + ) + elif shape == "CAPSULE": + bpyutils.makeCapsule( + radius=value[0], + height=value[1], + target_object=obj, + ) + mesh.update() + if rb: + rb.collision_shape = shape + else: + if shape == "SPHERE": + radius = max(value[0], 1e-3) + for v in mesh.vertices: + vec = v.co.normalized() + v.co = vec * radius + elif shape == "BOX": + x = max(value[0], 1e-3) + y = max(value[1], 1e-3) + z = max(value[2], 1e-3) + for v in mesh.vertices: + x0, y0, z0 = v.co + x0 = -x if x0 < 0 else x + y0 = -y if y0 < 0 else y + z0 = -z if z0 < 0 else z + v.co = [x0, y0, z0] + elif shape == "CAPSULE": + r0, h0, xx = FnRigidBody.get_rigid_body_size(prop.id_data) + h0 *= 0.5 + radius = max(value[0], 1e-3) + height = max(value[1], 1e-3) * 0.5 + scale = radius / max(r0, 1e-3) + for v in mesh.vertices: + x0, y0, z0 = v.co + x0 *= scale + y0 *= scale + if z0 < 0: + z0 = (z0 + h0) * scale - height + else: + z0 = (z0 - h0) * scale + height + v.co = [x0, y0, z0] + mesh.update() + + +def _get_rigid_name(prop): + return prop.get("name", "") + + +def _set_rigid_name(prop, value): + prop["name"] = value + + +class MMDRigidBody(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + get=_get_rigid_name, + set=_set_rigid_name, + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + collision_group_number: bpy.props.IntProperty( + name="Collision Group", + description="The collision group of the object", + min=0, + max=15, + default=1, + update=_updateCollisionGroup, + ) + + collision_group_mask: bpy.props.BoolVectorProperty( + name="Collision Group Mask", + description="The groups the object can not collide with", + size=16, + subtype="LAYER", + ) + + type: bpy.props.EnumProperty( + name="Rigid Type", + description="Select rigid type", + items=[ + (str(rigid_body.MODE_STATIC), "Bone", "Rigid body's orientation completely determined by attached bone", 1), + (str(rigid_body.MODE_DYNAMIC), "Physics", "Attached bone's orientation completely determined by rigid body", 2), + (str(rigid_body.MODE_DYNAMIC_BONE), "Physics + Bone", "Bone determined by combination of parent and attached rigid body", 3), + ], + update=_updateType, + ) + + shape: bpy.props.EnumProperty( + name="Shape", + description="Select the collision shape", + items=[ + ("SPHERE", "Sphere", "", 1), + ("BOX", "Box", "", 2), + ("CAPSULE", "Capsule", "", 3), + ], + update=_updateShape, + ) + + bone: bpy.props.StringProperty( + name="Bone", + description="Target bone", + default="", + get=_get_bone, + set=_set_bone, + ) + + size: bpy.props.FloatVectorProperty( + name="Size", + description="Size of the object", + subtype="XYZ", + size=3, + min=0, + step=0.1, + get=_get_size, + set=_set_size, + ) + + @staticmethod + def register(): + bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody)) + + @staticmethod + def unregister(): + del bpy.types.Object.mmd_rigid + + +def _updateSpringLinear(prop, context): + obj = prop.id_data + rbc = obj.rigid_body_constraint + if rbc: + rbc.spring_stiffness_x = prop.spring_linear[0] + rbc.spring_stiffness_y = prop.spring_linear[1] + rbc.spring_stiffness_z = prop.spring_linear[2] + + +def _updateSpringAngular(prop, context): + obj = prop.id_data + rbc = obj.rigid_body_constraint + if rbc and hasattr(rbc, "use_spring_ang_x"): + rbc.spring_stiffness_ang_x = prop.spring_angular[0] + rbc.spring_stiffness_ang_y = prop.spring_angular[1] + rbc.spring_stiffness_ang_z = prop.spring_angular[2] + + +class MMDJoint(bpy.types.PropertyGroup): + name_j: bpy.props.StringProperty( + name="Name", + description="Japanese Name", + default="", + ) + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + spring_linear: bpy.props.FloatVectorProperty( + name="Spring(Linear)", + description="Spring constant of movement", + subtype="XYZ", + size=3, + min=0, + step=0.1, + update=_updateSpringLinear, + ) + + spring_angular: bpy.props.FloatVectorProperty( + name="Spring(Angular)", + description="Spring constant of rotation", + subtype="XYZ", + size=3, + min=0, + step=0.1, + update=_updateSpringAngular, + ) + + @staticmethod + def register(): + bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint)) + + @staticmethod + def unregister(): + del bpy.types.Object.mmd_joint diff --git a/core/mmd/properties/root.py b/core/mmd/properties/root.py new file mode 100644 index 0000000..8188ed1 --- /dev/null +++ b/core/mmd/properties/root.py @@ -0,0 +1,577 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +"""Properties for MMD model root object""" + +import bpy + +from .. import utils +from ..bpyutils import FnContext +from ..core.material import FnMaterial +from ..core.model import FnModel +from ..core.sdef import FnSDEF +from . import patch_library_overridable +from .morph import BoneMorph, GroupMorph, MaterialMorph, UVMorph, VertexMorph +from .translations import MMDTranslation + + +def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1): + d = constraint.driver_add(path, index) + variables = d.driver.variables + for x in variables: + variables.remove(x) + return d.driver, variables + + +def __add_single_prop(variables, id_obj, data_path, prefix): + var = variables.new() + var.name = prefix + str(len(variables)) + var.type = "SINGLE_PROP" + target = var.targets[0] + target.id_type = "OBJECT" + target.id = id_obj + target.data_path = data_path + return var + + +def _toggleUsePropertyDriver(self: "MMDRoot", _context): + root_object: bpy.types.Object = self.id_data + armature_object = FnModel.find_armature_object(root_object) + + if armature_object is None: + ik_map = {} + 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: + 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 + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + 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 + for i in FnModel.iterate_mesh_objects(root_object): + for prop_hide in ("hide_viewport", "hide_render"): + driver, variables = __driver_variables(i, prop_hide) + driver.expression = "not %s" % __add_single_prop(variables, root_object, "mmd_root.show_meshes", "show").name + else: + for ik, (b, c) in ik_map.items(): + c.driver_remove("influence") + b = b if c.use_tail else b.parent + for b in ([b] + b.parent_recursive)[: c.chain_count]: + c = next((c for c in b.constraints if c.type == "LIMIT_ROTATION" and not c.mute), None) + if c: + c.driver_remove("influence") + for i in FnModel.iterate_mesh_objects(root_object): + for prop_hide in ("hide_viewport", "hide_render"): + i.driver_remove(prop_hide) + + +# =========================================== +# Callback functions +# =========================================== + + +def _toggleUseToonTexture(self: "MMDRoot", _context): + use_toon = self.use_toon_texture + 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): + use_sphere = self.use_sphere_texture + 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): + mute_sdef = not self.use_sdef + 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): + root = self.id_data + hide = not self.show_meshes + for i in FnModel.iterate_mesh_objects(self.id_data): + i.hide_set(hide) + i.hide_render = hide + if hide and context.active_object is None: + FnContext.set_active_object(context, root) + + +def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context): + root = self.id_data + hide = not self.show_rigid_bodies + 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): + root_object = self.id_data + hide = not self.show_joints + 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): + root_object: bpy.types.Object = self.id_data + hide = not self.show_temporary_objects + with FnContext.temp_override_active_layer_collection(context, root_object): + for i in FnModel.iterate_temporary_objects(root_object): + i.hide_set(hide) + if hide and context.active_object is None: + FnContext.set_active_object(context, root_object) + + +def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context): + root = self.id_data + show_names = root.mmd_root.show_names_of_rigid_bodies + for i in FnModel.iterate_rigid_body_objects(root): + i.show_name = show_names + + +def _toggleShowNamesOfJoints(self: "MMDRoot", _context): + root = self.id_data + show_names = root.mmd_root.show_names_of_joints + for i in FnModel.iterate_joint_objects(root): + i.show_name = show_names + + +def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool): + root = prop.id_data + arm = FnModel.find_armature_object(root) + if arm is None: + return + if not v and bpy.context.active_object == arm: + FnContext.set_active_object(bpy.context, root) + arm.hide_set(not v) + + +def _getVisibilityOfMMDRigArmature(prop: "MMDRoot"): + if prop.id_data.mmd_type != "ROOT": + return False + arm = FnModel.find_armature_object(prop.id_data) + return arm and not arm.hide_get() + + +def _setActiveRigidbodyObject(prop: "MMDRoot", v: int): + obj = FnContext.get_scene_objects(bpy.context)[v] + if FnModel.is_rigid_body_object(obj): + FnContext.set_active_and_select_single_object(bpy.context, obj) + prop["active_rigidbody_object_index"] = v + + +def _getActiveRigidbodyObject(prop: "MMDRoot"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_rigid_body_object(active_obj): + prop["active_rigidbody_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_rigidbody_object_index", 0) + + +def _setActiveJointObject(prop: "MMDRoot", v: int): + obj = FnContext.get_scene_objects(bpy.context)[v] + if FnModel.is_joint_object(obj): + FnContext.set_active_and_select_single_object(bpy.context, obj) + prop["active_joint_object_index"] = v + + +def _getActiveJointObject(prop: "MMDRoot"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_joint_object(active_obj): + prop["active_joint_object_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_joint_object_index", 0) + + +def _setActiveMorph(prop: "MMDRoot", v: bool): + if "active_morph_indices" not in prop: + prop["active_morph_indices"] = [0] * 5 + prop["active_morph_indices"][prop.get("active_morph_type", 3)] = v + + +def _getActiveMorph(prop: "MMDRoot"): + 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): + 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"): + context = bpy.context + active_obj = FnContext.get_active_object(context) + if FnModel.is_mesh_object(active_obj): + prop["active_mesh_index"] = FnContext.get_scene_objects(context).find(active_obj.name) + return prop.get("active_mesh_index", -1) + + +# =========================================== +# Property classes +# =========================================== + + +class MMDDisplayItem(bpy.types.PropertyGroup): + """PMX 表示項目(表示枠内の1項目)""" + + type: bpy.props.EnumProperty( + name="Type", + description="Select item type", + items=[ + ("BONE", "Bone", "", 1), + ("MORPH", "Morph", "", 2), + ], + ) + + morph_type: bpy.props.EnumProperty( + name="Morph Type", + description="Select morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + + +class MMDDisplayItemFrame(bpy.types.PropertyGroup): + """PMX 表示枠 + + PMXファイル内では表示枠がリストで格納されています。 + """ + + name_e: bpy.props.StringProperty( + name="Name(Eng)", + description="English Name", + default="", + ) + + # 特殊枠フラグ + # 特殊枠はファイル仕様上の固定枠(削除、リネーム不可) + is_special: bpy.props.BoolProperty( + name="Special", + description="Is special", + default=False, + ) + + # 表示項目のリスト + data: bpy.props.CollectionProperty( + name="Display Items", + type=MMDDisplayItem, + ) + + # 現在アクティブな項目のインデックス + active_item: bpy.props.IntProperty( + name="Active Display Item", + min=0, + default=0, + ) + + +class MMDRoot(bpy.types.PropertyGroup): + """MMDモデルデータ + + モデルルート用に作成されたEmtpyオブジェクトで使用します + """ + + name: bpy.props.StringProperty( + name="Name", + description="The name of the MMD model", + default="", + ) + + name_e: bpy.props.StringProperty( + name="Name (English)", + description="The english name of the MMD model", + default="", + ) + + comment_text: bpy.props.StringProperty( + name="Comment", + description="The text datablock of the comment", + default="", + ) + + comment_e_text: bpy.props.StringProperty( + name="Comment (English)", + description="The text datablock of the english comment", + default="", + ) + + ik_loop_factor: bpy.props.IntProperty( + name="MMD IK Loop Factor", + description="Scaling factor of MMD IK loop", + min=1, + soft_max=10, + max=100, + default=1, + ) + + # TODO: Replace to driver for NLA + show_meshes: bpy.props.BoolProperty( + name="Show Meshes", + description="Show all meshes of the MMD model", + # get=_show_meshes_get, + # set=_show_meshes_set, + update=_toggleVisibilityOfMeshes, + default=True, + ) + + show_rigid_bodies: bpy.props.BoolProperty( + name="Show Rigid Bodies", + description="Show all rigid bodies of the MMD model", + update=_toggleVisibilityOfRigidBodies, + ) + + show_joints: bpy.props.BoolProperty( + name="Show Joints", + description="Show all joints of the MMD model", + update=_toggleVisibilityOfJoints, + ) + + show_temporary_objects: bpy.props.BoolProperty( + name="Show Temps", + description="Show all temporary objects of the MMD model", + update=_toggleVisibilityOfTemporaryObjects, + ) + + show_armature: bpy.props.BoolProperty( + name="Show Armature", + description="Show the armature object of the MMD model", + get=_getVisibilityOfMMDRigArmature, + set=_setVisibilityOfMMDRigArmature, + ) + + show_names_of_rigid_bodies: bpy.props.BoolProperty( + name="Show Rigid Body Names", + description="Show rigid body names", + update=_toggleShowNamesOfRigidBodies, + ) + + show_names_of_joints: bpy.props.BoolProperty( + name="Show Joint Names", + description="Show joint names", + update=_toggleShowNamesOfJoints, + ) + + use_toon_texture: bpy.props.BoolProperty( + name="Use Toon Texture", + description="Use toon texture", + update=_toggleUseToonTexture, + default=True, + ) + + use_sphere_texture: bpy.props.BoolProperty( + name="Use Sphere Texture", + description="Use sphere texture", + update=_toggleUseSphereTexture, + default=True, + ) + + use_sdef: bpy.props.BoolProperty( + name="Use SDEF", + description="Use SDEF", + update=_toggleUseSDEF, + default=True, + ) + + use_property_driver: bpy.props.BoolProperty( + name="Use Property Driver", + description="Setup drivers for MMD property animation (Visibility and IK toggles)", + update=_toggleUsePropertyDriver, + default=False, + ) + + is_built: bpy.props.BoolProperty( + name="Is Built", + ) + + active_rigidbody_index: bpy.props.IntProperty( + name="Active Rigidbody Index", + min=0, + get=_getActiveRigidbodyObject, + set=_setActiveRigidbodyObject, + ) + + active_joint_index: bpy.props.IntProperty( + name="Active Joint Index", + min=0, + get=_getActiveJointObject, + set=_setActiveJointObject, + ) + + # ************************* + # Display Items + # ************************* + display_item_frames: bpy.props.CollectionProperty( + name="Display Frames", + type=MMDDisplayItemFrame, + ) + + active_display_item_frame: bpy.props.IntProperty( + name="Active Display Item Frame", + min=0, + default=0, + ) + + # ************************* + # Morph + # ************************* + material_morphs: bpy.props.CollectionProperty( + name="Material Morphs", + type=MaterialMorph, + ) + uv_morphs: bpy.props.CollectionProperty( + name="UV Morphs", + type=UVMorph, + ) + bone_morphs: bpy.props.CollectionProperty( + name="Bone Morphs", + type=BoneMorph, + ) + vertex_morphs: bpy.props.CollectionProperty(name="Vertex Morphs", type=VertexMorph) + group_morphs: bpy.props.CollectionProperty( + name="Group Morphs", + type=GroupMorph, + ) + active_morph_type: bpy.props.EnumProperty( + name="Active Morph Type", + description="Select current morph type", + items=[ + ("material_morphs", "Material", "Material Morphs", 0), + ("uv_morphs", "UV", "UV Morphs", 1), + ("bone_morphs", "Bone", "Bone Morphs", 2), + ("vertex_morphs", "Vertex", "Vertex Morphs", 3), + ("group_morphs", "Group", "Group Morphs", 4), + ], + default="vertex_morphs", + ) + active_morph: bpy.props.IntProperty( + name="Active Morph", + min=0, + set=_setActiveMorph, + get=_getActiveMorph, + ) + morph_panel_show_settings: bpy.props.BoolProperty( + name="Morph Panel Show Settings", + description="Show Morph Settings", + default=True, + ) + active_mesh_index: bpy.props.IntProperty( + name="Active Mesh", + min=0, + set=_setActiveMeshObject, + get=_getActiveMeshObject, + ) + + # ************************* + # Translation + # ************************* + translation: bpy.props.PointerProperty( + name="Translation", + type=MMDTranslation, + ) + + @staticmethod + def __get_select(prop: bpy.types.Object) -> bool: + utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") + return prop.select_get() + + @staticmethod + def __set_select(prop: bpy.types.Object, value: bool) -> None: + utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") + prop.select_set(value) + + @staticmethod + def __get_hide(prop: bpy.types.Object) -> bool: + utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") + return prop.hide_get() + + @staticmethod + def __set_hide(prop: bpy.types.Object, value: bool) -> None: + utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") + prop.hide_set(value) + if prop.hide_viewport != value: + prop.hide_viewport = value + + @staticmethod + def register(): + bpy.types.Object.mmd_type = patch_library_overridable( + bpy.props.EnumProperty( + name="Type", + description="Internal MMD type of this object (DO NOT CHANGE IT DIRECTLY)", + default="NONE", + items=[ + ("NONE", "None", "", 1), + ("ROOT", "Root", "", 2), + ("RIGID_GRP_OBJ", "Rigid Body Grp Empty", "", 3), + ("JOINT_GRP_OBJ", "Joint Grp Empty", "", 4), + ("TEMPORARY_GRP_OBJ", "Temporary Grp Empty", "", 5), + ("PLACEHOLDER", "Place Holder", "", 6), + ("CAMERA", "Camera", "", 21), + ("JOINT", "Joint", "", 22), + ("RIGID_BODY", "Rigid body", "", 23), + ("LIGHT", "Light", "", 24), + ("TRACK_TARGET", "Track Target", "", 51), + ("NON_COLLISION_CONSTRAINT", "Non Collision Constraint", "", 52), + ("SPRING_CONSTRAINT", "Spring Constraint", "", 53), + ("SPRING_GOAL", "Spring Goal", "", 54), + ], + ) + ) + bpy.types.Object.mmd_root = patch_library_overridable(bpy.props.PointerProperty(type=MMDRoot)) + + bpy.types.Object.select = patch_library_overridable( + bpy.props.BoolProperty( + get=MMDRoot.__get_select, + set=MMDRoot.__set_select, + options={ + "SKIP_SAVE", + "ANIMATABLE", + "LIBRARY_EDITABLE", + }, + ) + ) + bpy.types.Object.hide = patch_library_overridable( + bpy.props.BoolProperty( + get=MMDRoot.__get_hide, + set=MMDRoot.__set_hide, + options={ + "SKIP_SAVE", + "ANIMATABLE", + "LIBRARY_EDITABLE", + }, + ) + ) + + @staticmethod + def unregister(): + del bpy.types.Object.hide + del bpy.types.Object.select + del bpy.types.Object.mmd_root + del bpy.types.Object.mmd_type diff --git a/core/mmd/properties/translations.py b/core/mmd/properties/translations.py new file mode 100644 index 0000000..a70a9fc --- /dev/null +++ b/core/mmd/properties/translations.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +from typing import Dict, List, Optional, Tuple + +import bpy + +from ..core.translations import FnTranslations, MMDTranslationElementType +from ..translations import DictionaryEnum + +MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS = [ + (MMDTranslationElementType.BONE.name, MMDTranslationElementType.BONE.value, "Bones", 1), + (MMDTranslationElementType.MORPH.name, MMDTranslationElementType.MORPH.value, "Morphs", 2), + (MMDTranslationElementType.MATERIAL.name, MMDTranslationElementType.MATERIAL.value, "Materials", 4), + (MMDTranslationElementType.DISPLAY.name, MMDTranslationElementType.DISPLAY.value, "Display frames", 8), + (MMDTranslationElementType.PHYSICS.name, MMDTranslationElementType.PHYSICS.value, "Rigidbodies and joints", 16), + (MMDTranslationElementType.INFO.name, MMDTranslationElementType.INFO.value, "Model name and comments", 32), +] + + +class MMDTranslationElement(bpy.types.PropertyGroup): + type: bpy.props.EnumProperty(items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS) + object: bpy.props.PointerProperty(type=bpy.types.Object) + data_path: bpy.props.StringProperty() + name: bpy.props.StringProperty() + name_j: bpy.props.StringProperty() + name_e: bpy.props.StringProperty() + + +class MMDTranslationElementIndex(bpy.types.PropertyGroup): + value: bpy.props.IntProperty() + + +BATCH_OPERATION_SCRIPT_PRESETS: Dict[str, Tuple[Optional[str], str, str, int]] = { + "NOTHING": ("", "", "", 1), + "CLEAR": (None, "Clear", '""', 10), + "TO_ENGLISH": ("BLENDER", "Translate to English", "to_english(name)", 2), + "TO_MMD_LR": ("JAPANESE", "Blender L/R to MMD L/R", "to_mmd_lr(name)", 3), + "TO_BLENDER_LR": ("BLENDER", "MMD L/R to Blender L/R", "to_blender_lr(name_j)", 4), + "RESTORE_BLENDER": ("BLENDER", "Restore Blender Names", "org_name", 5), + "RESTORE_JAPANESE": ("JAPANESE", "Restore Japanese MMD Names", "org_name_j", 6), + "RESTORE_ENGLISH": ("ENGLISH", "Restore English MMD Names", "org_name_e", 7), + "ENGLISH_IF_EMPTY_JAPANESE": (None, "Copy English MMD Names, if empty copy Japanese MMD Name", "name_e if name_e else name_j", 8), + "JAPANESE_IF_EMPTY_ENGLISH": (None, "Copy Japanese MMD Names, if empty copy English MMD Name", "name_j if name_j else name_e", 9), +} + +BATCH_OPERATION_SCRIPT_PRESET_ITEMS: List[Tuple[str, str, str, int]] = [(k, t[1], t[2], t[3]) for k, t in BATCH_OPERATION_SCRIPT_PRESETS.items()] + + +class MMDTranslation(bpy.types.PropertyGroup): + @staticmethod + def _update_index(mmd_translation: "MMDTranslation", _context): + FnTranslations.update_index(mmd_translation) + + @staticmethod + def _collect_data(mmd_translation: "MMDTranslation", _context): + FnTranslations.collect_data(mmd_translation) + + @staticmethod + def _update_query(mmd_translation: "MMDTranslation", _context): + FnTranslations.update_query(mmd_translation) + + @staticmethod + def _update_batch_operation_script_preset(mmd_translation: "MMDTranslation", _context): + if mmd_translation.batch_operation_script_preset == "NOTHING": + return + + id2scripts: Dict[str, str] = {i[0]: i[2] for i in BATCH_OPERATION_SCRIPT_PRESET_ITEMS} + + batch_operation_script = id2scripts.get(mmd_translation.batch_operation_script_preset) + if batch_operation_script is None: + return + + mmd_translation.batch_operation_script = batch_operation_script + batch_operation_target = BATCH_OPERATION_SCRIPT_PRESETS[mmd_translation.batch_operation_script_preset][0] + if batch_operation_target: + mmd_translation.batch_operation_target = batch_operation_target + + translation_elements: bpy.props.CollectionProperty(type=MMDTranslationElement) + filtered_translation_element_indices_active_index: bpy.props.IntProperty(update=_update_index.__func__) + filtered_translation_element_indices: bpy.props.CollectionProperty(type=MMDTranslationElementIndex) + + filter_japanese_blank: bpy.props.BoolProperty(name="Japanese Blank", default=False, update=_update_query.__func__) + filter_english_blank: bpy.props.BoolProperty(name="English Blank", default=False, update=_update_query.__func__) + filter_restorable: bpy.props.BoolProperty(name="Restorable", default=False, update=_update_query.__func__) + filter_selected: bpy.props.BoolProperty(name="Selected", default=False, update=_update_query.__func__) + filter_visible: bpy.props.BoolProperty(name="Visible", default=False, update=_update_query.__func__) + filter_types: bpy.props.EnumProperty( + items=MMD_TRANSLATION_ELEMENT_TYPE_ENUM_ITEMS, + default={ + "BONE", + "MORPH", + "MATERIAL", + "DISPLAY", + "PHYSICS", + }, + options={"ENUM_FLAG"}, + update=_update_query.__func__, + ) + + dictionary: bpy.props.EnumProperty( + items=DictionaryEnum.get_dictionary_items, + name="Dictionary", + ) + + batch_operation_target: bpy.props.EnumProperty( + items=[ + ("BLENDER", "Blender Name (name)", "", 1), + ("JAPANESE", "Japanese MMD Name (name_j)", "", 2), + ("ENGLISH", "English MMD Name (name_e)", "", 3), + ], + name="Operation Target", + default="JAPANESE", + ) + + batch_operation_script_preset: bpy.props.EnumProperty( + items=BATCH_OPERATION_SCRIPT_PRESET_ITEMS, + name="Operation Script Preset", + default="NOTHING", + update=_update_batch_operation_script_preset.__func__, + ) + + batch_operation_script: bpy.props.StringProperty() diff --git a/core/mmd/translations.py b/core/mmd/translations.py new file mode 100644 index 0000000..b7f5e3c --- /dev/null +++ b/core/mmd/translations.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import csv +import logging +import time + +import bpy + +from .bpyutils import FnContext + +jp_half_to_full_tuples = ( + ("ヴ", "ヴ"), + ("ガ", "ガ"), + ("ギ", "ギ"), + ("グ", "グ"), + ("ゲ", "ゲ"), + ("ゴ", "ゴ"), + ("ザ", "ザ"), + ("ジ", "ジ"), + ("ズ", "ズ"), + ("ゼ", "ゼ"), + ("ゾ", "ゾ"), + ("ダ", "ダ"), + ("ヂ", "ヂ"), + ("ヅ", "ヅ"), + ("デ", "デ"), + ("ド", "ド"), + ("バ", "バ"), + ("パ", "パ"), + ("ビ", "ビ"), + ("ピ", "ピ"), + ("ブ", "ブ"), + ("プ", "プ"), + ("ベ", "ベ"), + ("ペ", "ペ"), + ("ボ", "ボ"), + ("ポ", "ポ"), + ("。", "。"), + ("「", "「"), + ("」", "」"), + ("、", "、"), + ("・", "・"), + ("ヲ", "ヲ"), + ("ァ", "ァ"), + ("ィ", "ィ"), + ("ゥ", "ゥ"), + ("ェ", "ェ"), + ("ォ", "ォ"), + ("ャ", "ャ"), + ("ュ", "ュ"), + ("ョ", "ョ"), + ("ッ", "ッ"), + ("ー", "ー"), + ("ア", "ア"), + ("イ", "イ"), + ("ウ", "ウ"), + ("エ", "エ"), + ("オ", "オ"), + ("カ", "カ"), + ("キ", "キ"), + ("ク", "ク"), + ("ケ", "ケ"), + ("コ", "コ"), + ("サ", "サ"), + ("シ", "シ"), + ("ス", "ス"), + ("セ", "セ"), + ("ソ", "ソ"), + ("タ", "タ"), + ("チ", "チ"), + ("ツ", "ツ"), + ("テ", "テ"), + ("ト", "ト"), + ("ナ", "ナ"), + ("ニ", "ニ"), + ("ヌ", "ヌ"), + ("ネ", "ネ"), + ("ノ", "ノ"), + ("ハ", "ハ"), + ("ヒ", "ヒ"), + ("フ", "フ"), + ("ヘ", "ヘ"), + ("ホ", "ホ"), + ("マ", "マ"), + ("ミ", "ミ"), + ("ム", "ム"), + ("メ", "メ"), + ("モ", "モ"), + ("ヤ", "ヤ"), + ("ユ", "ユ"), + ("ヨ", "ヨ"), + ("ラ", "ラ"), + ("リ", "リ"), + ("ル", "ル"), + ("レ", "レ"), + ("ロ", "ロ"), + ("ワ", "ワ"), + ("ン", "ン"), +) + +jp_to_en_tuples = [ + ("全ての親", "ParentNode"), + ("操作中心", "ControlNode"), + ("センター", "Center"), + ("センター", "Center"), + ("グループ", "Group"), + ("グルーブ", "Groove"), + ("キャンセル", "Cancel"), + ("上半身", "UpperBody"), + ("下半身", "LowerBody"), + ("手首", "Wrist"), + ("足首", "Ankle"), + ("首", "Neck"), + ("頭", "Head"), + ("顔", "Face"), + ("下顎", "Chin"), + ("下あご", "Chin"), + ("あご", "Jaw"), + ("顎", "Jaw"), + ("両目", "Eyes"), + ("目", "Eye"), + ("眉", "Eyebrow"), + ("舌", "Tongue"), + ("涙", "Tears"), + ("泣き", "Cry"), + ("歯", "Teeth"), + ("照れ", "Blush"), + ("青ざめ", "Pale"), + ("ガーン", "Gloom"), + ("汗", "Sweat"), + ("怒", "Anger"), + ("感情", "Emotion"), + ("符", "Marks"), + ("暗い", "Dark"), + ("腰", "Waist"), + ("髪", "Hair"), + ("三つ編み", "Braid"), + ("胸", "Breast"), + ("乳", "Boob"), + ("おっぱい", "Tits"), + ("筋", "Muscle"), + ("腹", "Belly"), + ("鎖骨", "Clavicle"), + ("肩", "Shoulder"), + ("腕", "Arm"), + ("うで", "Arm"), + ("ひじ", "Elbow"), + ("肘", "Elbow"), + ("手", "Hand"), + ("親指", "Thumb"), + ("人指", "IndexFinger"), + ("人差指", "IndexFinger"), + ("中指", "MiddleFinger"), + ("薬指", "RingFinger"), + ("小指", "LittleFinger"), + ("足", "Leg"), + ("ひざ", "Knee"), + ("つま", "Toe"), + ("袖", "Sleeve"), + ("新規", "New"), + ("ボーン", "Bone"), + ("捩", "Twist"), + ("回転", "Rotation"), + ("軸", "Axis"), + ("ネクタイ", "Necktie"), + ("ネクタイ", "Necktie"), + ("ヘッドセット", "Headset"), + ("飾り", "Accessory"), + ("リボン", "Ribbon"), + ("襟", "Collar"), + ("紐", "String"), + ("コード", "Cord"), + ("イヤリング", "Earring"), + ("メガネ", "Eyeglasses"), + ("眼鏡", "Glasses"), + ("帽子", "Hat"), + ("スカート", "Skirt"), + ("スカート", "Skirt"), + ("パンツ", "Pantsu"), + ("シャツ", "Shirt"), + ("フリル", "Frill"), + ("マフラー", "Muffler"), + ("マフラー", "Muffler"), + ("服", "Clothes"), + ("ブーツ", "Boots"), + ("ねこみみ", "CatEars"), + ("ジップ", "Zip"), + ("ジップ", "Zip"), + ("ダミー", "Dummy"), + ("ダミー", "Dummy"), + ("基", "Category"), + ("あほ毛", "Antenna"), + ("アホ毛", "Antenna"), + ("モミアゲ", "Sideburn"), + ("もみあげ", "Sideburn"), + ("ツインテ", "Twintail"), + ("おさげ", "Pigtail"), + ("ひらひら", "Flutter"), + ("調整", "Adjustment"), + ("補助", "Aux"), + ("右", "Right"), + ("左", "Left"), + ("前", "Front"), + ("後ろ", "Behind"), + ("後", "Back"), + ("横", "Side"), + ("中", "Middle"), + ("上", "Upper"), + ("下", "Lower"), + ("親", "Parent"), + ("先", "Tip"), + ("パーツ", "Part"), + ("光", "Light"), + ("戻", "Return"), + ("羽", "Wing"), + ("根", "Base"), # ideally 'Root' but to avoid confusion + ("毛", "Strand"), + ("尾", "Tail"), + ("尻", "Butt"), + # full-width unicode forms I think: https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms + ("0", "0"), + ("1", "1"), + ("2", "2"), + ("3", "3"), + ("4", "4"), + ("5", "5"), + ("6", "6"), + ("7", "7"), + ("8", "8"), + ("9", "9"), + ("a", "a"), + ("b", "b"), + ("c", "c"), + ("d", "d"), + ("e", "e"), + ("f", "f"), + ("g", "g"), + ("h", "h"), + ("i", "i"), + ("j", "j"), + ("k", "k"), + ("l", "l"), + ("m", "m"), + ("n", "n"), + ("o", "o"), + ("p", "p"), + ("q", "q"), + ("r", "r"), + ("s", "s"), + ("t", "t"), + ("u", "u"), + ("v", "v"), + ("w", "w"), + ("x", "x"), + ("y", "y"), + ("z", "z"), + ("A", "A"), + ("B", "B"), + ("C", "C"), + ("D", "D"), + ("E", "E"), + ("F", "F"), + ("G", "G"), + ("H", "H"), + ("I", "I"), + ("J", "J"), + ("K", "K"), + ("L", "L"), + ("M", "M"), + ("N", "N"), + ("O", "O"), + ("P", "P"), + ("Q", "Q"), + ("R", "R"), + ("S", "S"), + ("T", "T"), + ("U", "U"), + ("V", "V"), + ("W", "W"), + ("X", "X"), + ("Y", "Y"), + ("Z", "Z"), + ("+", "+"), + ("-", "-"), + ("_", "_"), + ("/", "/"), + (".", "_"), # probably should be combined with the global 'use underscore' option +] + + +def translateFromJp(name): + for tuple in jp_to_en_tuples: + if tuple[0] in name: + name = name.replace(tuple[0], tuple[1]) + return name + + +def getTranslator(csvfile="", keep_order=False): + translator = MMDTranslator() + if isinstance(csvfile, bpy.types.Text): + translator.load_from_stream(csvfile) + elif isinstance(csvfile, dict): + translator.csv_tuples.extend(csvfile.items()) + elif csvfile in bpy.data.texts.keys(): + translator.load_from_stream(bpy.data.texts[csvfile]) + else: + translator.load(csvfile) + + if not keep_order: + translator.sort() + translator.update() + return translator + + +class MMDTranslator: + def __init__(self): + self.__csv_tuples = [] + self.__fails = {} + + @staticmethod + def default_csv_filepath(): + return __file__[:-3] + ".csv" + + @staticmethod + def get_csv_text(text_name=None): + text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath()) + csv_text = bpy.data.texts.get(text_name, None) + if csv_text is None: + csv_text = bpy.data.texts.new(text_name) + return csv_text + + @staticmethod + def replace_from_tuples(name, tuples): + for pair in tuples: + if pair[0] in name: + name = name.replace(pair[0], pair[1]) + return name + + @property + def csv_tuples(self): + return self.__csv_tuples + + @property + def fails(self): + return self.__fails + + def sort(self): + self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row)) + + def update(self): + 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) + + def half_to_full(self, name): + return self.replace_from_tuples(name, jp_half_to_full_tuples) + + def is_translated(self, name): + try: + name.encode("ascii", errors="strict") + except UnicodeEncodeError: + return False + return True + + def translate(self, name, default=None, from_full_width=True): + if from_full_width: + name = self.half_to_full(name) + name_new = self.replace_from_tuples(name, self.__csv_tuples) + if default is not None and not self.is_translated(name_new): + self.__fails[name] = name_new + return default + return name_new + + def save_fails(self, text_name=None): + text_name = text_name or (__name__ + ".fails") + txt = self.get_csv_text(text_name) + fmt = '"%s","%s"' + items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row)) + txt.from_string("\n".join(fmt % (k, v) for k, v in items)) + return txt + + def load_from_stream(self, csvfile=None): + csvfile = csvfile or self.get_csv_text() + if isinstance(csvfile, bpy.types.Text): + csvfile = (l.body + "\n" for l in csvfile.lines) + 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)) + + def save_to_stream(self, csvfile=None): + csvfile = csvfile or self.get_csv_text() + lineterminator = "\r\n" + if isinstance(csvfile, bpy.types.Text): + csvfile.clear() + 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)) + + def load(self, filepath=None): + 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) + + def save(self, filepath=None): + 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) + + +class DictionaryEnum: + __items_ttl = 0.0 + __items_cache = None + + @staticmethod + def get_dictionary_items(prop, context): + if DictionaryEnum.__items_ttl > time.time(): + return DictionaryEnum.__items_cache + + DictionaryEnum.__items_ttl = time.time() + 5 + DictionaryEnum.__items_cache = items = [] + if "import" in prop.bl_rna.identifier: + items.append(("DISABLED", "Disabled", "", 0)) + + 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))) + + import os + + folder = FnContext.get_addon_preferences_attribute(context, "dictionary_folder", "") + if os.path.isdir(folder): + for filename in sorted(x for x in os.listdir(folder) if x.lower().endswith(".csv")): + filepath = os.path.join(folder, filename) + if os.path.isfile(filepath): + items.append((filepath, filename, filepath, "FILE", len(items))) + + if "dictionary" in prop: + prop["dictionary"] = min(prop["dictionary"], len(items) - 1) + return items + + @staticmethod + def get_translator(dictionary): + if dictionary == "DISABLED": + return None + if dictionary == "INTERNAL": + return getTranslator(dict(jp_to_en_tuples)) + return getTranslator(dictionary) diff --git a/core/mmd/utils.py b/core/mmd/utils.py new file mode 100644 index 0000000..c4006ac --- /dev/null +++ b/core/mmd/utils.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 MMD Tools authors +# This file was originally part of the MMD Tools add-on for Blender +# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools +# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. + +import logging +import os +import re +from typing import Callable, Optional, Set + +import bpy + +from .bpyutils import FnContext + + +## 指定したオブジェクトのみを選択状態かつアクティブにする +def selectAObject(obj): + try: + bpy.ops.object.mode_set(mode="OBJECT") + except Exception: + pass + bpy.ops.object.select_all(action="DESELECT") + FnContext.select_object(FnContext.ensure_context(), obj) + FnContext.set_active_object(FnContext.ensure_context(), obj) + + +## 現在のモードを指定したオブジェクトのEdit Modeに変更する +def enterEditMode(obj): + selectAObject(obj) + if obj.mode != "EDIT": + bpy.ops.object.mode_set(mode="EDIT") + + +def setParentToBone(obj, parent, bone_name): + selectAObject(obj) + FnContext.set_active_object(FnContext.ensure_context(), parent) + bpy.ops.object.mode_set(mode="POSE") + parent.data.bones.active = parent.data.bones[bone_name] + bpy.ops.object.parent_set(type="BONE", xmirror=False, keep_transform=False) + bpy.ops.object.mode_set(mode="OBJECT") + + +def selectSingleBone(context, armature, bone_name, reset_pose=False): + try: + bpy.ops.object.mode_set(mode="OBJECT") + except: + pass + for i in context.selected_objects: + i.select_set(False) + FnContext.set_active_object(context, armature) + bpy.ops.object.mode_set(mode="POSE") + if reset_pose: + for p_bone in armature.pose.bones: + p_bone.matrix_basis.identity() + armature_bones: bpy.types.ArmatureBones = armature.data.bones + i: bpy.types.Bone + for i in armature_bones: + i.select = i.name == bone_name + i.select_head = i.select_tail = i.select + if i.select: + armature_bones.active = i + i.hide = False + + +__CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$") +__CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$") + + +## 日本語で左右を命名されている名前をblender方式のL(R)に変更する +def convertNameToLR(name, use_underscore=False): + m = __CONVERT_NAME_TO_L_REGEXP.match(name) + delimiter = "_" if use_underscore else "." + if m: + name = m.group(1) + m.group(2) + delimiter + "L" + m = __CONVERT_NAME_TO_R_REGEXP.match(name) + if m: + name = m.group(1) + m.group(2) + delimiter + "R" + return name + + +__CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[lL])(?P($|(?P=separator)))") +__CONVERT_R_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[rR])(?P($|(?P=separator)))") + + +def convertLRToName(name): + match = __CONVERT_L_TO_NAME_REGEXP.search(name) + if match: + return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}" + + match = __CONVERT_R_TO_NAME_REGEXP.search(name) + if match: + return f"右{name[0:match.start()]}{match['after']}{name[match.end():]}" + + return name + + +## src_vertex_groupのWeightをdest_vertex_groupにaddする +def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name): + mesh = meshObj.data + src_vertex_group = meshObj.vertex_groups[src_vertex_group_name] + dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name] + + vtxIndex = src_vertex_group.index + for v in mesh.vertices: + try: + gi = [i.group for i in v.groups].index(vtxIndex) + dest_vertex_group.add([v.index], v.groups[gi].weight, "ADD") + except ValueError: + pass + + +def separateByMaterials(meshObj: bpy.types.Object): + if len(meshObj.data.materials) < 2: + selectAObject(meshObj) + return + matrix_parent_inverse = meshObj.matrix_parent_inverse.copy() + prev_parent = meshObj.parent + dummy_parent = bpy.data.objects.new(name="tmp", object_data=None) + meshObj.parent = dummy_parent + meshObj.active_shape_key_index = 0 + try: + enterEditMode(meshObj) + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.separate(type="MATERIAL") + finally: + bpy.ops.object.mode_set(mode="OBJECT") + for i in dummy_parent.children: + materials = i.data.materials + i.name = getattr(materials[0], "name", "None") if len(materials) else "None" + i.parent = prev_parent + i.matrix_parent_inverse = matrix_parent_inverse + bpy.data.objects.remove(dummy_parent) + + +def clearUnusedMeshes(): + meshes_to_delete = [] + for mesh in bpy.data.meshes: + if mesh.users == 0: + meshes_to_delete.append(mesh) + + for mesh in meshes_to_delete: + bpy.data.meshes.remove(mesh) + + +## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を +# それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成 +def makePmxBoneMap(armObj): + # 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} + + +__REMOVE_PREFIX_DIGITS_REGEXP = re.compile(r"\.\d{1,}$") + + +def unique_name(name: str, used_names: Set[str]) -> str: + """Helper function for storing unique names. + This function is a limited and simplified version of bpy_extras.io_utils.unique_name. + + Args: + name (str): The name to make unique. + used_names (Set[str]): A set of names that are already used. + + Returns: + str: The unique name, formatted as "{name}.{number:03d}". + """ + if name not in used_names: + return name + count = 1 + new_name = orig_name = __REMOVE_PREFIX_DIGITS_REGEXP.sub("", name) + while new_name in used_names: + new_name = f"{orig_name}.{count:03d}" + count += 1 + return new_name + + +def int2base(x, base, width=0): + """ + Method to convert an int to a base + Source: http://stackoverflow.com/questions/2267362 + """ + import string + + digs = string.digits + string.ascii_uppercase + assert 2 <= base <= len(digs) + digits, negtive = "", False + if x <= 0: + if x == 0: + return "0" * max(1, width) + x, negtive, width = -x, True, width - 1 + while x: + digits = digs[x % base] + digits + x //= base + digits = "0" * (width - len(digits)) + digits + if negtive: + digits = "-" + digits + return digits + + +def saferelpath(path, start, strategy="inside"): + """ + On Windows relpath will raise a ValueError + when trying to calculate the relative path to a + different drive. + This method will behave different depending on the strategy + choosen to handle the different drive issue. + Strategies: + - inside: this will just return the basename of the path given + - outside: this will prepend '..' to the basename + - absolute: this will return the absolute path instead of a relative. + See http://bugs.python.org/issue7195 + """ + if strategy == "inside": + return os.path.basename(path) + + if strategy == "absolute": + return os.path.abspath(path) + + if strategy == "outside" and os.name == "nt": + d1, _ = os.path.splitdrive(path) + d2, _ = os.path.splitdrive(start) + if d1 != d2: + return ".." + os.sep + os.path.basename(path) + + return os.path.relpath(path, start) + +class ItemOp: + @staticmethod + def get_by_index(items, index): + if 0 <= index < len(items): + return items[index] + return None + + @staticmethod + def resize(items: bpy.types.bpy_prop_collection, length: int): + count = length - len(items) + if count > 0: + for i in range(count): + items.add() + elif count < 0: + for i in range(-count): + items.remove(length) + + @staticmethod + def add_after(items, index): + index_end = len(items) + index = max(0, min(index_end, index + 1)) + items.add() + items.move(index_end, index) + return items[index], index + + +class ItemMoveOp: + type: bpy.props.EnumProperty( + name="Type", + description="Move type", + items=[ + ("UP", "Up", "", 0), + ("DOWN", "Down", "", 1), + ("TOP", "Top", "", 2), + ("BOTTOM", "Bottom", "", 3), + ], + default="UP", + ) + + @staticmethod + def move(items, index, move_type, index_min=0, index_max=None): + if index_max is None: + index_max = len(items) - 1 + else: + index_max = min(index_max, len(items) - 1) + index_min = min(index_min, index_max) + + if index < index_min: + items.move(index, index_min) + return index_min + elif index > index_max: + items.move(index, index_max) + return index_max + + index_new = index + if move_type == "UP": + index_new = max(index_min, index - 1) + elif move_type == "DOWN": + index_new = min(index + 1, index_max) + elif move_type == "TOP": + index_new = index_min + elif move_type == "BOTTOM": + index_new = index_max + + if index_new != index: + items.move(index, index_new) + return index_new + + +def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None): + """Decorator to mark a function as deprecated. + Args: + deprecated_in (Optional[str]): Version in which the function was deprecated. + details (Optional[str]): Additional details about the deprecation. + Returns: + Callable: The decorated function. + """ + + def _function_wrapper(function: Callable): + def _inner_wrapper(*args, **kwargs): + warn_deprecation(function.__name__, deprecated_in, details) + return function(*args, **kwargs) + + return _inner_wrapper + + return _function_wrapper + + +def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, details: Optional[str] = None) -> None: + """Reports a deprecation warning. + Args: + function_name (str): Name of the deprecated function. + deprecated_in (Optional[str]): Version in which the function was deprecated. + details (Optional[str]): Additional details about the deprecation. + """ + logging.warning( + "%s is deprecated%s%s", + function_name, + f" since {deprecated_in}" if deprecated_in else "", + f": {details}" if details else "", + 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/properties.py b/core/properties.py index 898a6dc..ef9243d 100644 --- a/core/properties.py +++ b/core/properties.py @@ -43,6 +43,13 @@ def update_logging_state(self: PropertyGroup, context: Context) -> None: from .logging_setup import configure_logging configure_logging(self.enable_logging) +def update_log_level(self: PropertyGroup, context: Context) -> None: + """Updates log level and configures logging""" + logger.info(f"Updating log level to: {self.log_level}") + save_preference("log_level", self.log_level) + from .logging_setup import configure_logging + configure_logging(self.enable_logging, self.log_level) + def update_shape_intensity(self: PropertyGroup, context: Context) -> None: """Updates shape key intensity and refreshes preview""" @@ -588,6 +595,19 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=True ) + log_level: EnumProperty( + name=t("Settings.log_level"), + description=t("Settings.log_level_desc"), + items=[ + ('DEBUG', t("Settings.log_level.debug"), t("Settings.log_level.debug_desc")), + ('INFO', t("Settings.log_level.info"), t("Settings.log_level.info_desc")), + ('WARNING', t("Settings.log_level.warning"), t("Settings.log_level.warning_desc")), + ('ERROR', t("Settings.log_level.error"), t("Settings.log_level.error_desc")), + ], + default=get_preference("log_level", "WARNING"), + update=update_log_level + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 2c642d6..98ef378 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -524,6 +524,16 @@ "Settings.highlight_problem_bones": "Highlight Problem Bones", "Settings.highlight_problem_bones_desc": "Highlight bones with validation issues in the viewport", "Settings.bone_highlighting": "Bone Highlighting", + "Settings.log_level": "Log Level", + "Settings.log_level_desc": "Select the detail level for debug logging", + "Settings.log_level.debug": "Debug", + "Settings.log_level.debug_desc": "Show all log messages including detailed debug information", + "Settings.log_level.info": "Info", + "Settings.log_level.info_desc": "Show informational messages, warnings and errors", + "Settings.log_level.warning": "Warning", + "Settings.log_level.warning_desc": "Show only warnings and errors", + "Settings.log_level.error": "Error", + "Settings.log_level.error_desc": "Show only error messages", "Language.auto": "Automatic", "Language.en_US": "English", "Language.ja_JP": "Japanese", diff --git a/resources/translations/ja_JP.json b/resources/translations/ja_JP.json index 22282b0..a37a9a9 100644 --- a/resources/translations/ja_JP.json +++ b/resources/translations/ja_JP.json @@ -514,6 +514,16 @@ "Settings.highlight_problem_bones": "問題のあるボーンを強調表示", "Settings.highlight_problem_bones_desc": "ビューポートで検証に問題のあるボーンを強調表示", "Settings.bone_highlighting": "ボーンの強調表示", + "Settings.log_level": "ログレベル", + "Settings.log_level_desc": "デバッグログの詳細レベルを選択", + "Settings.log_level.debug": "デバッグ", + "Settings.log_level.debug_desc": "詳細なデバッグ情報を含むすべてのログメッセージを表示", + "Settings.log_level.info": "情報", + "Settings.log_level.info_desc": "情報メッセージ、警告、エラーを表示", + "Settings.log_level.warning": "警告", + "Settings.log_level.warning_desc": "警告とエラーのみを表示", + "Settings.log_level.error": "エラー", + "Settings.log_level.error_desc": "エラーメッセージのみを表示", "Language.auto": "自動", "Language.en_US": "英語", "Language.ja_JP": "日本語", diff --git a/resources/translations/ko_KR.json b/resources/translations/ko_KR.json index f80a09d..c8408cc 100644 --- a/resources/translations/ko_KR.json +++ b/resources/translations/ko_KR.json @@ -514,6 +514,16 @@ "Settings.highlight_problem_bones": "문제 본 강조 표시", "Settings.highlight_problem_bones_desc": "뷰포트에서 검증 문제가 있는 본 강조 표시", "Settings.bone_highlighting": "본 강조 표시", + "Settings.log_level": "로그 레벨", + "Settings.log_level_desc": "디버그 로깅의 상세 수준 선택", + "Settings.log_level.debug": "디버그", + "Settings.log_level.debug_desc": "상세한 디버그 정보를 포함한 모든 로그 메시지 표시", + "Settings.log_level.info": "정보", + "Settings.log_level.info_desc": "정보 메시지, 경고 및 오류 표시", + "Settings.log_level.warning": "경고", + "Settings.log_level.warning_desc": "경고 및 오류만 표시", + "Settings.log_level.error": "오류", + "Settings.log_level.error_desc": "오류 메시지만 표시", "Language.auto": "자동", "Language.en_US": "영어", "Language.ja_JP": "일본어", diff --git a/ui/settings_panel.py b/ui/settings_panel.py index 0036ef6..bdaa783 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -89,3 +89,6 @@ class AvatarToolKit_PT_SettingsPanel(Panel): if props.debug_expand: col = debug_box.column(align=True) col.prop(props, "enable_logging") + + if props.enable_logging: + col.prop(props, "log_level") \ No newline at end of file From bb5a314796fdabad1d5a2c18088e46a14564f44f Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sat, 12 Apr 2025 00:17:11 +0100 Subject: [PATCH 18/32] Bringing files in-line with Avatar Toolkit - Adding better typing - Update to use Avatar Toolkit's logging system. - Removed some files which were in the wrong location (From my first attempt). --- core/lamp.py | 66 ---------- core/mmd/bpyutils.py | 149 ++++++++++++---------- core/mmd/cycles_converter.py | 75 ++++++----- core/mmd/utils.py | 52 ++++---- cycles_converter.py | 240 ----------------------------------- 5 files changed, 153 insertions(+), 429 deletions(-) delete mode 100644 core/lamp.py delete mode 100644 cycles_converter.py 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/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/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/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) From 19c2ede791f9d3f09259001dacd2634f2b3b1ea5 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 16 Apr 2025 16:17:57 +0100 Subject: [PATCH 19/32] Update Translation.py --- core/mmd/translations.py | 129 ++++++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 37 deletions(-) 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) From d1af3fffed2957862a0988c772b9d52265ff17df Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 16 Apr 2025 19:02:16 +0100 Subject: [PATCH 20/32] Update importer --- core/mmd/core/pmx/importer.py | 613 +++++++++++++++++++++++++--------- 1 file changed, 447 insertions(+), 166 deletions(-) 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}"') From bf92ca905bc2d4cdbf307aede2b053b7e26b5f65 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 17 Apr 2025 00:02:18 +0100 Subject: [PATCH 21/32] Upfate Bone and Camrea --- core/mmd/core/bone.py | 244 ++++++++++++++++++++---------- core/mmd/core/camera.py | 325 ++++++++++++++++++++++++---------------- 2 files changed, 363 insertions(+), 206 deletions(-) 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}") From 61e42697644ca3d671ccf56b2b04b6cd4cb65560 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Tue, 22 Apr 2025 00:28:47 +0100 Subject: [PATCH 22/32] Update Files and Fixes --- core/mmd/core/bone.py | 11 +- core/mmd/core/camera.py | 2 +- core/mmd/core/lamp.py | 43 +++++-- core/mmd/core/material.py | 188 +++++++++++++++++---------- core/mmd/core/model.py | 244 +++++++++++++++++++++++------------- core/mmd/core/morph.py | 120 +++++++++--------- core/mmd/core/rigid_body.py | 47 +++++-- core/mmd/core/sdef.py | 84 ++++++++----- core/mmd/core/shader.py | 92 ++++++++------ 9 files changed, 524 insertions(+), 307 deletions(-) diff --git a/core/mmd/core/bone.py b/core/mmd/core/bone.py index 45b16fd..29b490e 100644 --- a/core/mmd/core/bone.py +++ b/core/mmd/core/bone.py @@ -15,14 +15,14 @@ from bpy.types import Object, EditBone, PoseBone, Constraint, Armature, BoneColl from .. import bpyutils from ..bpyutils import TransformConstraintOp from ..utils import ItemOp -from ....logging_setup import logger +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: bpy.types.ConstraintSequence, name: str) -> bool: +def remove_constraint(constraints: Any, name: str) -> bool: """Remove a constraint by name if it exists""" c = constraints.get(name, None) if c: @@ -30,7 +30,6 @@ def remove_constraint(constraints: bpy.types.ConstraintSequence, name: str) -> b return True return False - def remove_edit_bones(edit_bones: bpy.types.ArmatureEditBones, bone_names: List[str]) -> None: """Remove edit bones by name""" for name in bone_names: @@ -573,7 +572,7 @@ class _AT_ShadowBoneRemove: 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: bpy.types.ArmaturePoseBones) -> None: + def update_pose_bones(self, pose_bones: Any) -> None: """Update pose bones (no-op for removal)""" pass @@ -628,13 +627,13 @@ class _AT_ShadowBoneCreate: shadow.roll = bone.roll logger.debug(f"Created/updated shadow bone: {shadow_bone_name}") - def update_pose_bones(self, pose_bones: bpy.types.ArmaturePoseBones) -> None: + 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" diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py index 752520c..4c45d80 100644 --- a/core/mmd/core/camera.py +++ b/core/mmd/core/camera.py @@ -13,7 +13,7 @@ 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 +from ....core.logging_setup import logger class FnCamera: @staticmethod 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..c60f929 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 @@ -213,7 +214,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 +223,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 +261,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 +282,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 +355,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 +374,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 +389,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 +401,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 +423,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 +435,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 +488,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 +521,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 +556,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 +568,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 +601,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 +617,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 +636,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 +644,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 +657,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 +681,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 +740,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 +758,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 +776,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 +790,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 +798,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 +837,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 +867,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 +902,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 +939,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 +991,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 +1009,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 +1022,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 +1031,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 +1081,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 +1095,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 +1125,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 +1133,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 +1163,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 +1190,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 +1214,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 +1233,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 +1243,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 +1254,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/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 From cfe760e8df7b4ea8c909caad47d7ecb824fc74c9 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 23 Apr 2025 00:43:38 +0100 Subject: [PATCH 23/32] Updated Operations and Properties - Updated Operations and Properties with tpying and logging. I have not updated translation files, this is because i want to gut MMD Tools system and replace it with our own, however I want to make MMD Tools more simple and ajust it to our needs only. This is going to take a while and my aim for this is Alpha 4, also the MMD Translation system hurt my head.... - Fixes a couple of bugs as well, with quick access and the PMX importer. --- blender_manifest.toml | 2 +- core/armature_validation.py | 29 +++++++- core/mmd/core/model.py | 6 +- core/mmd/operators/material.py | 120 ++++++++++++++++++++---------- core/mmd/operators/misc.py | 83 ++++++++++++++------- core/mmd/operators/model.py | 85 +++++++++++++++------ core/mmd/operators/model_edit.py | 103 +++++++++++++++++-------- core/mmd/operators/morph.py | 91 ++++++++++++++-------- core/mmd/operators/rigid_body.py | 62 +++++++++------ core/mmd/operators/sdef.py | 28 ++++--- core/mmd/operators/view.py | 76 ++++++++++--------- core/mmd/properties/material.py | 44 ++++++----- core/mmd/properties/morph.py | 50 +++++++------ core/mmd/properties/pose_bone.py | 30 +++++--- core/mmd/properties/rigid_body.py | 64 +++++++++------- core/mmd/properties/root.py | 66 +++++++++------- core/updater.py | 2 +- resources/translations/en_US.json | 9 ++- resources/translations/ja_JP.json | 9 ++- resources/translations/ko_KR.json | 9 ++- ui/quick_access_panel.py | 68 ++++++++++++++--- 21 files changed, 689 insertions(+), 347 deletions(-) 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/mmd/core/model.py b/core/mmd/core/model.py index c60f929..ab22433 100644 --- a/core/mmd/core/model.py +++ b/core/mmd/core/model.py @@ -41,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]: 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/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/resources/translations/en_US.json b/resources/translations/en_US.json index e29ef01..9e89d6c 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 22282b0..6ba5a72 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 f80a09d..ca35b5a 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') + From 8937077e3a477b8fd20e59d468cd72b05c6de28b Mon Sep 17 00:00:00 2001 From: 989onan Date: Sun, 15 Jun 2025 19:31:30 -0400 Subject: [PATCH 24/32] fix issues with merge armatures please report these issues lol!! I found this after I got my friend to test the addon. OOF!! --- core/common.py | 1 + functions/custom_tools/armature_merging.py | 15 +++++++++++---- functions/custom_tools/force_apply_modifier.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/core/common.py b/core/common.py index e0093e9..2220f9e 100644 --- a/core/common.py +++ b/core/common.py @@ -313,6 +313,7 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional for mesh in valid_meshes: mesh.select_set(True) + mesh.hide_set(False) context.view_layer.objects.active = valid_meshes[0] diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index 7cd0807..c305d13 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -4,6 +4,7 @@ from typing import List, Optional, Dict, Set, Tuple, Any from bpy.types import Context, Object, Operator, ArmatureModifier, EditBone, VertexGroup, Mesh, ShapeKey from ...core.logging_setup import logger from ...core.translations import t +import traceback from ...core.common import ( get_all_meshes, fix_zero_length_bones, @@ -73,8 +74,8 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): return {'FINISHED'} except Exception as e: - logger.error(f"Error merging armatures: {str(e)}") - self.report({'ERROR'}, str(e)) + logger.error(f"Error merging armatures:", exception=e) + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def delete_rigidbodies_and_joints(armature: Object) -> None: @@ -149,6 +150,9 @@ def merge_armatures( # Store meshes that need to be reparented meshes_to_reparent = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == merge_armature] + + base_armature.hide_set(False) + merge_armature.hide_set(False) # Check transforms early if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance): @@ -170,6 +174,8 @@ def merge_armatures( fix_zero_length_bones(base_armature) fix_zero_length_bones(merge_armature) + + # Store original parent relationships original_parents: Dict[str, Optional[str]] = {} merge_armature_data: bpy.types.Armature = merge_armature.data @@ -187,9 +193,9 @@ def merge_armatures( for standard,bone_name in identified_bone_names_source.items(): if standard in identifed_base_bone_names: #if the bone we are at on our merge armature has a standard name translation for the target armature merge_armature_data.edit_bones[bone_name].name = identifed_base_bone_names[standard] #change it's name to the one on the target merge to armature's coorisponding standard bone - bone_name = merge_armature_data.edit_bones[bone_name].name + bone_name = identifed_base_bone_names[standard] #adjust original parents list to point to the new name. - for child_bone in merge_armature_data.edit_bones[bone_name]: + for child_bone in merge_armature_data.edit_bones[bone_name].children: original_parents[child_bone.name] = bone_name #then remove so it doesn't clash when merged. merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name]) @@ -201,6 +207,7 @@ def merge_armatures( bpy.ops.object.select_all(action='DESELECT') base_armature.select_set(True) merge_armature.select_set(True) + bpy.context.view_layer.objects.active = base_armature bpy.ops.object.join() diff --git a/functions/custom_tools/force_apply_modifier.py b/functions/custom_tools/force_apply_modifier.py index 2d478ab..d0446e3 100644 --- a/functions/custom_tools/force_apply_modifier.py +++ b/functions/custom_tools/force_apply_modifier.py @@ -27,7 +27,7 @@ from ...core.armature_validation import validate_armature class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): """Operator for forcing the application of a modifier. A shortened way of saying \"Apply modifier for object with shapekeys\"""" - bl_idname: str = 'avatar_toolkit.merge_armatures' + bl_idname: str = 'avatar_toolkit.apply_shapekey_force' bl_label: str = t('Tools.apply_modifier_on_shapekey_obj') bl_description: str = t('Tools.apply_modifier_on_shapekey_obj_desc') bl_options: Set[str] = {'REGISTER', 'UNDO'} From 9e00234f0d1446010105ae9ec3a6cebd50d9da00 Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 20 Jun 2025 21:56:24 -0400 Subject: [PATCH 25/32] unfuck digitgrade leg tools the heck happened here!? anyways it's working --- functions/tools/bone_tools.py | 39 +++++++++-------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 9df48ce..d7204fe 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -11,6 +11,7 @@ from ...core.common import ( remove_unused_vertex_groups, identify_bones, ) +import traceback from ...core.armature_validation import validate_armature, validate_bone_hierarchy def duplicate_bone(bone: EditBone) -> EditBone: @@ -41,25 +42,7 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): context.mode == 'EDIT_ARMATURE' and context.selected_editable_bones is not None and len(context.selected_editable_bones) == 2) - - def store_bone_chain_data(self, digi0: EditBone) -> Dict[str, Any]: - """Store initial bone chain data""" - chain_data = {} - current = digi0 - while current: - chain_data[current.name] = { - 'head': current.head.copy(), - 'tail': current.tail.copy(), - 'roll': current.roll, - 'matrix': current.matrix.copy(), - 'parent': current.parent.name if current.parent else None - } - if current.children: - current = current.children[0] - else: - break - return chain_data - + def process_leg_chain(self, digi0: EditBone) -> bool: """Process a single leg bone chain""" try: @@ -74,24 +57,22 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): bone.select = True bpy.ops.armature.roll_clear() bpy.ops.armature.select_all(action='DESELECT') - - # Create thigh bone - thigh = duplicate_bone(digi0) - base_name = digi0.name.split('.')[0] - thigh.name = base_name # Create and position calf bone calf = duplicate_bone(digi1) calf.name = digi1.name.split('.')[0] - calf.parent = thigh + calf.parent = digi0 # Calculate new positions - midpoint = (digi1.tail + digi2.tail) * 0.5 - calf.head = thigh.tail - calf.tail = midpoint + end = ((digi0.tail) + (digi2.tail-digi2.head)) + calf.head = end + calf.tail = digi2.tail # Reparent foot to new calf digi3.parent = calf + + #enforce parallelagram onto midparts. + digi1.tail = (digi0.tail)+(calf.tail-calf.head) # Mark original bones as non-IK for bone in [digi0, digi1, digi2]: @@ -101,7 +82,7 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): return True except Exception as e: - self.report({'ERROR'}, t("Tools.digitigrade_error", error=str(e))) + self.report({'ERROR'}, t("Tools.digitigrade_error", error=traceback.format_exc())) return False def execute(self, context: Context) -> set[str]: From 5be65501b41ba0179c9505eaae8a09880724766d Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 20 Jun 2025 22:52:11 -0400 Subject: [PATCH 26/32] oops still not perfect, here --- functions/tools/bone_tools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index d7204fe..218a3c3 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -64,9 +64,12 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): calf.parent = digi0 # Calculate new positions - end = ((digi0.tail) + (digi2.tail-digi2.head)) + + + end = (((digi0.tail-digi0.head)*(1/digi0.length))*(digi0.length+digi2.length) + digi0.head) calf.head = end - calf.tail = digi2.tail + calf.tail = (digi1.tail-digi1.head)+calf.head + digi2.tail = calf.tail # Reparent foot to new calf digi3.parent = calf From 1fcd1ad07d3ae3f9fc678b1e5a36f1963e077496 Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 20 Jun 2025 22:58:09 -0400 Subject: [PATCH 27/32] REEEE --- functions/tools/bone_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 218a3c3..cd13930 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -78,7 +78,7 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): digi1.tail = (digi0.tail)+(calf.tail-calf.head) # Mark original bones as non-IK - for bone in [digi0, digi1, digi2]: + for bone in [digi1, digi2]: if "" not in bone.name: bone.name = bone.name.split('.')[0] + "" From d31519a51dec937405cff1f3698cf842ec7e08cc Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 4 Jul 2025 16:55:41 -0400 Subject: [PATCH 28/32] update again --- functions/tools/bone_tools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index cd13930..ae5de3b 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -77,6 +77,8 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): #enforce parallelagram onto midparts. digi1.tail = (digi0.tail)+(calf.tail-calf.head) + calf.name = calf.name.replace("","") + # Mark original bones as non-IK for bone in [digi1, digi2]: if "" not in bone.name: From 89fc8bc9c8d5b4abbb83c080834d532cb38ded0a Mon Sep 17 00:00:00 2001 From: 989onan Date: Mon, 7 Jul 2025 13:14:00 -0400 Subject: [PATCH 29/32] Update visemes.py - fix viseme creation needing an armature (idk why it needed this) --- functions/visemes.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/functions/visemes.py b/functions/visemes.py index 889306a..a7365a8 100644 --- a/functions/visemes.py +++ b/functions/visemes.py @@ -8,11 +8,9 @@ from collections import OrderedDict from ..core.logging_setup import logger from ..core.translations import t from ..core.common import ( - get_active_armature, get_all_meshes, validate_mesh_for_pose ) -from ..core.armature_validation import validate_armature class VisemeCache: """Manages caching of generated viseme shape data for performance optimization""" @@ -140,12 +138,8 @@ class AvatarToolkit_OT_PreviewVisemes(Operator): props = context.scene.avatar_toolkit mesh_obj = bpy.data.objects.get(props.viseme_mesh) - # Validate armature and mesh - armature = get_active_armature(context) - if not armature: - return False - valid, _, _ = validate_armature(armature) - return valid and mesh_obj and mesh_obj.type == 'MESH' + # Validate mesh + return mesh_obj and mesh_obj.type == 'MESH' def execute(self, context: Context) -> Set[str]: props = context.scene.avatar_toolkit @@ -198,12 +192,8 @@ class AvatarToolkit_OT_CreateVisemes(Operator): props = context.scene.avatar_toolkit mesh_obj = bpy.data.objects.get(props.viseme_mesh) - # Validate armature and mesh - armature = get_active_armature(context) - if not armature: - return False - valid, _, _ = validate_armature(armature) - return valid and mesh_obj and mesh_obj.type == 'MESH' + # Validate mesh + return mesh_obj and mesh_obj.type == 'MESH' def execute(self, context: Context) -> Set[str]: props = context.scene.avatar_toolkit From 6d9f751a163c4ce73ae0559e22687cfd35235f27 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 10 Jul 2025 18:44:42 -0400 Subject: [PATCH 30/32] Housekeeping (bug fixes) NEW FEATURES: - added apply shapekey to basis from Cats - now that pesky thing I keep going back to cats for is in Avatar Toolkit. BUG FIXES: - now we push armature santizers into functions where they are needed - this prevents the methods from mirroring changes while working, causing them to blow up when mirror mode is on - more changes to come for armature setting santitizers - fixed error reporting - now methods when catching errors will return full error tracebacks - this will help make debugging and finding user issues easier. --- __init__.py | 5 + core/common.py | 37 +- core/importers/importer.py | 9 +- core/mmd/core/camera.py | 21 +- core/mmd/operators/material.py | 5 +- core/resonite_utils.py | 11 +- functions/atlas_materials.py | 3 +- functions/custom_tools/__init__.py | 0 functions/custom_tools/armature_merging.py | 9 + .../custom_tools/force_apply_modifier.py | 11 +- functions/custom_tools/mesh_attachment.py | 16 +- functions/eye_tracking.py | 15 +- functions/optimization/materials_tools.py | 19 +- functions/optimization/mesh_tools.py | 13 +- functions/optimization/remove_doubles.py | 4 +- functions/pose_mode.py | 25 +- functions/tools/additional_tools.py | 13 +- functions/tools/apply_shapekey_to_basis.py | 451 ++++++++++++++++++ functions/tools/bone_tools.py | 37 +- functions/tools/merge_tools.py | 47 +- functions/tools/mesh_separation.py | 9 +- functions/tools/rigify_converter.py | 7 +- functions/tools/standardize_armature.py | 17 +- functions/tools/uv_tools.py | 5 +- functions/visemes.py | 7 +- resources/translations/en_US.json | 5 + ui/atlas_materials_panel.py | 5 +- 27 files changed, 663 insertions(+), 143 deletions(-) create mode 100644 functions/custom_tools/__init__.py create mode 100644 functions/tools/apply_shapekey_to_basis.py diff --git a/__init__.py b/__init__.py index 1b5d169..2b8b692 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,7 @@ import bpy from bpy.app.handlers import persistent + modules = None ordered_classes = None @@ -42,6 +43,10 @@ def register(): log_level = get_preference("log_level", "WARNING") configure_logging(get_preference("enable_logging", False), log_level) + #this needs to be done last, or at least after whatever things this uses is imported - @989onan + from .functions.tools.apply_shapekey_to_basis import add_to_menu + bpy.types.MESH_MT_shape_key_context_menu.append(add_to_menu) + print("Registration complete") def unregister(): diff --git a/core/common.py b/core/common.py index 2220f9e..d1785b2 100644 --- a/core/common.py +++ b/core/common.py @@ -1,3 +1,4 @@ +import traceback import bpy import numpy as np import threading @@ -199,9 +200,9 @@ def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Obje return True, t("Operation.pose_applied") - except Exception as e: - logger.error(f"Error applying pose as rest: {str(e)}") - return False, str(e) + except Exception: + logger.error(f"Error applying pose as rest: {traceback.format_exc()}") + return False, traceback.format_exc() def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: """Apply armature deformation to mesh""" @@ -335,8 +336,8 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional return joined_mesh - except Exception as e: - logger.error(f"Failed to join meshes: {str(e)}") + except Exception: + logger.error(f"Failed to join meshes: {traceback.format_exc()}") return None @@ -365,8 +366,8 @@ def fix_uv_coordinates(context: Context) -> None: logger.debug(f"UV Fix - Successfully processed {obj.name}") - except Exception as e: - logger.warning(f"UV Fix - Skipped processing for {obj.name}: {str(e)}") + except Exception: + logger.warning(f"UV Fix - Skipped processing for {obj.name}: {traceback.format_exc()}") finally: bpy.ops.object.mode_set(mode='OBJECT') @@ -488,7 +489,6 @@ def fix_zero_length_bones(armature: Object) -> None: """Fix zero length bones by setting a minimum length""" if not armature: return - bpy.ops.object.mode_set(mode='EDIT') for bone in armature.data.edit_bones: if bone.length < 0.001: @@ -631,6 +631,7 @@ def get_objects() -> bpy.types.BlendData: def duplicate_bone(bone: EditBone) -> EditBone: """Create a duplicate of the given bone""" + new_bone: EditBone = bone.id_data.edit_bones.new(bone.name + "_copy") new_bone.head = bone.head.copy() new_bone.tail = bone.tail.copy() @@ -642,14 +643,18 @@ def duplicate_bone(bone: EditBone) -> EditBone: new_bone.use_deform = bone.use_deform return new_bone -#Binary tools - - - - -#encoding FrooxEngine/C# types in binary: - - + + +class ArmatureData(Tuple[bool,bool]): + pass + +def store_breaking_settings_armature(armature: bpy.types.Object) -> ArmatureData: + armature_data: bpy.types.Armature = armature.data + return (armature_data.use_mirror_x, armature.pose.use_mirror_x) + +def restore_breaking_settings_armature(armature: bpy.types.Object, data: ArmatureData) -> None: + armature_data: bpy.types.Armature = armature.data + armature_data.use_mirror_x, armature.pose.use_mirror_x = data diff --git a/core/importers/importer.py b/core/importers/importer.py index 237fc92..d8000bb 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -9,6 +9,7 @@ from typing import Optional, Callable, Dict, List, Union, Set from ..common import clear_default_objects from ..translations import t from ..mmd.core.pmx.importer import PMXImporter +import traceback # Configure logging logging.basicConfig(level=logging.INFO) @@ -84,8 +85,8 @@ def import_multi_files( progress_callback(fullpath) progress.update(file["name"]) - except Exception as e: - logger.error(f"Import failed: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Import failed: {traceback.format_exc()}", exc_info=True) raise ImportMethod = Callable[[str, List[Dict[str, str]], str], None] @@ -230,6 +231,6 @@ def import_pmx_file(filepath: str) -> None: try: importer.execute(**import_settings) logger.info(f"Successfully imported PMX file: {filepath}") - except Exception as e: - logger.error(f"Failed to import PMX file: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Failed to import PMX file: {traceback.format_exc()}", exc_info=True) raise diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py index 4c45d80..3c6da6e 100644 --- a/core/mmd/core/camera.py +++ b/core/mmd/core/camera.py @@ -11,6 +11,7 @@ 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 +import traceback from ..bpyutils import FnContext, Props from ....core.logging_setup import logger @@ -87,8 +88,8 @@ class FnCamera: __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)}") + except Exception: + logger.error(f"Failed to add drivers to camera {camera_object.name}: {traceback.format_exc()}") @staticmethod def remove_drivers(camera_object: Object) -> None: @@ -100,8 +101,8 @@ class FnCamera: 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)}") + except Exception: + logger.error(f"Failed to remove drivers from camera {camera_object.name}: {traceback.format_exc()}") class MigrationFnCamera: @@ -124,8 +125,8 @@ class MigrationFnCamera: 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)}") + except Exception: + logger.error(f"Failed to update MMD camera {camera_object.name}: {traceback.format_exc()}") logger.info(f"Updated {updated_count} MMD cameras") @@ -197,8 +198,8 @@ class MMDCamera: 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)}") + except Exception: + logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {traceback.format_exc()}") raise @staticmethod @@ -305,8 +306,8 @@ class MMDCamera: 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)}") + except Exception: + logger.error(f"Failed to create MMD camera animation: {traceback.format_exc()}") raise def object(self) -> Object: diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py index a6ea15a..caa76fe 100644 --- a/core/mmd/operators/material.py +++ b/core/mmd/operators/material.py @@ -16,6 +16,7 @@ from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial from ..core.shader import _NodeGroupUtils from ....core.logging_setup import logger +import traceback class ConvertMaterialsForCycles(Operator): @@ -50,8 +51,8 @@ class ConvertMaterialsForCycles(Operator): def execute(self, context: Context) -> Set[str]: try: context.scene.render.engine = "CYCLES" - except Exception as e: - logger.error(f"Failed to change to Cycles render engine: {str(e)}") + except Exception: + logger.error(f"Failed to change to Cycles render engine: {traceback.format_exc()}") self.report({"ERROR"}, " * Failed to change to Cycles render engine.") return {"CANCELLED"} diff --git a/core/resonite_utils.py b/core/resonite_utils.py index 02d56ed..b5e539f 100644 --- a/core/resonite_utils.py +++ b/core/resonite_utils.py @@ -4,6 +4,7 @@ import bpy_extras from numpy import double from typing import Set, Dict import re +import traceback from .common import get_active_armature, ProgressTracker, identify_bones from bpy.types import Context, Operator @@ -91,16 +92,16 @@ class AvatarToolkit_OT_ConvertResonite(Operator): progress.step(t("Tools.convert_resonite.processing", name=bone.name)) - except Exception as e: - logger.error(f"Error during Resonite conversion: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Error during Resonite conversion: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} finally: try: bpy.ops.object.mode_set(mode='OBJECT') - except Exception as e: - logger.warning(f"Error returning to object mode: {str(e)}") + except Exception: + logger.warning(f"Error returning to object mode: {traceback.format_exc()}") if translate_bone_fails > 0: logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones") diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py index 8c38f9e..c50cf17 100644 --- a/functions/atlas_materials.py +++ b/functions/atlas_materials.py @@ -8,6 +8,7 @@ from ..core.common import SceneMatClass, MaterialListBool, ProgressTracker from ..core.packer.rectangle_packer import MaterialImageList, BinPacker from ..core.translations import t from ..core.logging_setup import logger +import traceback class MaterialImageList: def __init__(self): @@ -306,6 +307,6 @@ class AvatarToolKit_OT_AtlasMaterials(Operator): return {"FINISHED"} except Exception as e: - logger.error(f"Error creating material atlas: {str(e)}", exc_info=True) + logger.error(f"Error creating material atlas: {traceback.format_exc()}", exc_info=True) self.report({'ERROR'}, t("TextureAtlas.atlas_error")) raise e diff --git a/functions/custom_tools/__init__.py b/functions/custom_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index c305d13..d65afa2 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -13,6 +13,8 @@ from ...core.common import ( join_mesh_objects, remove_unused_shapekeys, identify_bones, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) from ...core.dictionaries import simplify_bonename @@ -42,6 +44,9 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): logger.error(f"Armature not found: {merge_armature_name}") self.report({'ERROR'}, t('MergeArmature.error.not_found', name=merge_armature_name)) return {'CANCELLED'} + #Store current armature settings that can mess us up. + data_breaking_base = store_breaking_settings_armature(base_armature) + data_breaking_merge = store_breaking_settings_armature(merge_armature) # Remove Rigid Bodies and Joints delete_rigidbodies_and_joints(base_armature) @@ -70,7 +75,11 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): wm.progress_update(100) wm.progress_end() + restore_breaking_settings_armature(base_armature, data_breaking_base) + restore_breaking_settings_armature(merge_armature, data_breaking_merge) + self.report({'INFO'}, t('MergeArmature.success')) + return {'FINISHED'} except Exception as e: diff --git a/functions/custom_tools/force_apply_modifier.py b/functions/custom_tools/force_apply_modifier.py index d0446e3..266c263 100644 --- a/functions/custom_tools/force_apply_modifier.py +++ b/functions/custom_tools/force_apply_modifier.py @@ -117,14 +117,11 @@ class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): obj.select_set(True) context.view_layer.objects.active = obj bpy.ops.object.join_shapes() - except Exception as e: + except Exception: self.report({'ERROR'}, f"Shapekey joining failed!!") print(f"Shapekey joining failed!!") - print(traceback.format_exc(e)) - #clean up after critical failure - for shape in shapes: - bpy.data.objects.remove(shape)#faster than ops delete + print(traceback.format_exc()) #final clean up for shape in shapes: @@ -136,4 +133,6 @@ class AvatarToolkit_OT_ApplyModifierForShapkeyObj(bpy.types.Operator): - return {'FINISHED'} \ No newline at end of file + return {'FINISHED'} + + diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py index d660e76..20d4900 100644 --- a/functions/custom_tools/mesh_attachment.py +++ b/functions/custom_tools/mesh_attachment.py @@ -2,6 +2,7 @@ import bpy from bpy.types import Operator, Context, Object, ArmatureModifier, VertexGroup from mathutils import Vector from typing import Set, Optional, List, Any +import traceback from ...core.logging_setup import logger from ...core.translations import t @@ -10,7 +11,9 @@ from ...core.common import ( get_all_meshes, ProgressTracker, calculate_bone_orientation, - add_armature_modifier + add_armature_modifier, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) from ...core.armature_validation import validate_armature @@ -83,11 +86,11 @@ class AvatarToolkit_OT_AttachMesh(Operator): attach_to_bone = armature.data.edit_bones.get(attach_bone_name) if not attach_to_bone: raise ValueError(t("AttachMesh.error.bone_not_found", bone=attach_bone_name)) - + data_breaking = store_breaking_settings_armature(armature) mesh_bone = armature.data.edit_bones.new(mesh_name) mesh_bone.parent = attach_to_bone progress.step(t("AttachMesh.create_bone")) - + # Calculate bone placement verts_in_group: List[Any] = [v for v in mesh.data.vertices for g in v.groups if g.group == vg.index] @@ -104,6 +107,7 @@ class AvatarToolkit_OT_AttachMesh(Operator): mesh_bone.head = center mesh_bone.tail = center + Vector((0, 0, max(0.1, dimensions.z))) mesh_bone.roll = roll_angle + restore_breaking_settings_armature(armature, data_breaking) progress.step(t("AttachMesh.position_bone")) bpy.ops.object.mode_set(mode='OBJECT') @@ -114,9 +118,9 @@ class AvatarToolkit_OT_AttachMesh(Operator): self.report({'INFO'}, t("AttachMesh.success")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to attach mesh: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to attach mesh: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def validate_mesh_transforms(mesh: Optional[Object]) -> tuple[bool, str]: diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py index e720286..0dcf653 100644 --- a/functions/eye_tracking.py +++ b/functions/eye_tracking.py @@ -10,6 +10,7 @@ from typing import Optional, Dict, Tuple, Set, List, Any, Union, ClassVar from collections import OrderedDict from random import random from itertools import chain +import traceback from ..core.logging_setup import logger from ..core.translations import t @@ -104,8 +105,8 @@ class CreateEyesAV3Button(bpy.types.Operator): self.report({'INFO'}, t('EyeTracking.success')) return {'FINISHED'} - except Exception as e: - logger.error(f"Eye tracking setup failed: {str(e)}") + except Exception: + logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class CreateEyesSDK2Button(bpy.types.Operator): @@ -197,7 +198,7 @@ class CreateEyesSDK2Button(bpy.types.Operator): return {'FINISHED'} except Exception as e: - logger.error(f"Eye tracking setup failed: {str(e)}") + logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class EyeTrackingBackup: @@ -222,8 +223,8 @@ class EyeTrackingBackup: with open(self.backup_path, 'w') as f: json.dump(self.bone_positions, f) return True - except Exception as e: - logger.error(f"Backup failed: {str(e)}") + except Exception: + logger.error(f"Backup failed: {traceback.format_exc()}") return False def restore_bone_positions(self, armature) -> bool: @@ -243,8 +244,8 @@ class EyeTrackingBackup: bone.tail = positions['tail'] return True - except Exception as e: - logger.error(f"Restore failed: {str(e)}") + except Exception: + logger.error(f"Restore failed: {traceback.format_exc()}") return False class EyeTrackingValidator: diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py index b6983d4..9dfee9b 100644 --- a/functions/optimization/materials_tools.py +++ b/functions/optimization/materials_tools.py @@ -18,6 +18,7 @@ from ...core.common import ( ProgressTracker ) from ...core.armature_validation import validate_armature +import traceback def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool: """Compare two texture nodes for matching properties and image data""" @@ -112,24 +113,24 @@ class AvatarToolkit_OT_CombineMaterials(Operator): with ProgressTracker(context, 4, "Combining Materials") as progress: try: num_combined = self.consolidate_materials(meshes) - except Exception as e: - logger.error(f"Material consolidation failed: {str(e)}") + except Exception: + logger.error(f"Material consolidation failed: {traceback.format_exc()}") self.report({'ERROR'}, t("Optimization.error.consolidation")) return {'CANCELLED'} progress.step("Consolidated materials") try: num_cleaned = self.clean_material_slots(meshes) - except Exception as e: - logger.error(f"Material slot cleanup failed: {str(e)}") + except Exception: + logger.error(f"Material slot cleanup failed: {traceback.format_exc()}") self.report({'ERROR'}, t("Optimization.error.slot_cleanup")) return {'CANCELLED'} progress.step("Cleaned material slots") try: num_removed = clear_unused_data_blocks() - except Exception as e: - logger.error(f"Data block cleanup failed: {str(e)}") + except Exception: + logger.error(f"Data block cleanup failed: {traceback.format_exc()}") self.report({'ERROR'}, t("Optimization.error.data_cleanup")) return {'CANCELLED'} progress.step("Removed unused data blocks") @@ -141,9 +142,9 @@ class AvatarToolkit_OT_CombineMaterials(Operator): return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to combine materials: {str(e)}") - self.report({'ERROR'}, t("Optimization.error.combine_materials", error=str(e))) + except Exception: + logger.error(f"Failed to combine materials: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.combine_materials", error=traceback.format_exc())) return {'CANCELLED'} def consolidate_materials(self, meshes: List[Object]) -> int: diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py index 825b493..1870871 100644 --- a/functions/optimization/mesh_tools.py +++ b/functions/optimization/mesh_tools.py @@ -11,6 +11,7 @@ from ...core.common import ( ProgressTracker ) from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_JoinAllMeshes(Operator): """Operator to join all meshes in the scene""" @@ -51,9 +52,9 @@ class AvatarToolkit_OT_JoinAllMeshes(Operator): self.report({'ERROR'}, t("Optimization.error.join_meshes")) return {'CANCELLED'} - except Exception as e: - logger.error(f"Failed to join meshes: {str(e)}") - self.report({'ERROR'}, t("Optimization.error.join_meshes", error=str(e))) + except Exception: + logger.error(f"Failed to join meshes: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.join_meshes", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_JoinSelectedMeshes(Operator): @@ -95,7 +96,7 @@ class AvatarToolkit_OT_JoinSelectedMeshes(Operator): self.report({'ERROR'}, t("Optimization.error.join_selected")) return {'CANCELLED'} - except Exception as e: - logger.error(f"Failed to join selected meshes: {str(e)}") - self.report({'ERROR'}, t("Optimization.error.join_selected", error=str(e))) + except Exception: + logger.error(f"Failed to join selected meshes: {traceback.format_exc()}") + self.report({'ERROR'}, t("Optimization.error.join_selected", error=traceback.format_exc())) return {'CANCELLED'} diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index 4240d93..c7aede2 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -119,8 +119,8 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): context.window_manager.modal_handler_add(self) return {'RUNNING_MODAL'} - except Exception as e: - logger.error(f"Error in execute: {str(e)}") + except Exception: + logger.error(f"Error in execute: {traceback.format_exc()}") return {'CANCELLED'} def modal(self, context: Context, event: Event) -> set[ModalReturnType]: diff --git a/functions/pose_mode.py b/functions/pose_mode.py index 6cf2b00..1a15e19 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -14,6 +14,7 @@ from ..core.common import ( process_armature_modifiers, ProgressTracker ) +import traceback from ..core.armature_validation import validate_armature class BatchPoseOperationMixin: @@ -62,9 +63,9 @@ class AvatarToolkit_OT_StartPoseMode(Operator): bpy.ops.object.mode_set(mode='POSE') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to start pose mode: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.start", error=str(e))) + except Exception: + logger.error(f"Failed to start pose mode: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.start", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_StopPoseMode(Operator): @@ -85,9 +86,9 @@ class AvatarToolkit_OT_StopPoseMode(Operator): bpy.ops.pose.select_all(action="INVERT") bpy.ops.object.mode_set(mode='OBJECT') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to stop pose mode: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e))) + except Exception: + logger.error(f"Failed to stop pose mode: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.stop", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): @@ -129,9 +130,9 @@ class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): progress.step(f"Processed {mesh_obj.name}") return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply pose as shape key: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e))) + except Exception: + logger.error(f"Failed to apply pose as shape key: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.shapekey", error=traceback.format_exc())) return {'CANCELLED'} class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): @@ -160,7 +161,7 @@ class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): logger.info("Successfully applied pose as rest") return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply pose as rest: {str(e)}") - self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=str(e))) + except Exception: + logger.error(f"Failed to apply pose as rest: {traceback.format_exc()}") + self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=traceback.format_exc())) return {'CANCELLED'} diff --git a/functions/tools/additional_tools.py b/functions/tools/additional_tools.py index 91afaee..c7830d7 100644 --- a/functions/tools/additional_tools.py +++ b/functions/tools/additional_tools.py @@ -6,6 +6,7 @@ from ...core.translations import t from ...core.logging_setup import logger from ...core.common import get_active_armature, get_all_meshes, remove_unused_shapekeys from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ApplyTransforms(Operator): """Apply all transformations to armature and associated meshes""" @@ -42,9 +43,9 @@ class AvatarToolkit_OT_ApplyTransforms(Operator): self.report({'INFO'}, t("Tools.transforms_applied")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to apply transforms: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to apply transforms: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} class AvatarToolkit_OT_CleanShapekeys(Operator): @@ -86,7 +87,7 @@ class AvatarToolkit_OT_CleanShapekeys(Operator): self.report({'INFO'}, t("Tools.shapekeys_removed", count=removed_count)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to clean shape keys: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to clean shape keys: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} diff --git a/functions/tools/apply_shapekey_to_basis.py b/functions/tools/apply_shapekey_to_basis.py new file mode 100644 index 0000000..1e74aba --- /dev/null +++ b/functions/tools/apply_shapekey_to_basis.py @@ -0,0 +1,451 @@ +# GPL License +import bpy +import numpy as np +from ...core.translations import t +from typing import Set + +class AvatarToolkit_OT_ShapeKeyApplier(bpy.types.Operator): + # Applies the currently active shape key with its current value and vertex group to the 'Basis' shape key and all + # shape keys recursively relative to the 'Basis' shape key. + # Turns the currently active shape key into a shape key that reverts the original application if applied. + bl_idname: str = "avatar_toolkit.shape_key_to_basis" + bl_label: str = t('Tools.shapekey_to_basis.label') + bl_description: str = t('Tools.shapekey_to_basis.desc') + bl_options: Set[str] = {'REGISTER', 'UNDO', 'INTERNAL'} + + @classmethod + def poll(cls, context): + # Note that context.object.active_shape_key_index is 0 if there are no shape keys + # So context.object.active_shape_key_index > 0 simultaneously checks that there are shape keys and that the + # active shape key isn't the first one + return (context.mode == 'OBJECT' and + context.object and + # Could be extended to other types that have shape keys, but only MESH supported for now + context.object.type == 'MESH' and + # If the active shape key is the basis, nothing would be done + context.object.active_shape_key_index > 0 and + # If the shapes aren't relative, using relative keys to apply to the basis and all affected keys would + # be wrong and the idea of having a key to revert the change doesn't make sense + context.object.data.shape_keys.use_relative and + # If the active shape key is relative to itself, then it does nothing + context.object.active_shape_key.relative_key != context.object.active_shape_key) + + def execute(self, context): + # If an object other than the active object is to be used, it can be specified using a context override + mesh = context.object + + # Get shapekey which will be the new basis + new_basis_shapekey = mesh.active_shape_key + + # Create a map of key : [keys relative to key] + # Effectively the reverse of the key.relative_key relation + reverse_relative_map = AvatarToolkit_OT_ShapeKeyApplier.ReverseRelativeMap(mesh) + + # new_basis_shapekey will only be included if it's relative to itself (new_basis_shapekey cannot be the first shape key as poll() ensures + # that the index of the active shape key is greater than 0) + keys_relative_recursive_to_new_basis = reverse_relative_map.get_relative_recursive_keys(new_basis_shapekey) + + # Cancel execution if the new basis shape key is relative to itself (via a loop, since poll already returns false for being immediately relative to itself since that will always do nothing) + # If the relative keys loop back around, then if the key is turned into its reverse after applying, it would affect all keys that it's relative to + # Key1 relative -> Key2 + # Key2 relative -> Key1 + # If Key1 is applied to Basis, Key1 should be changed to a reverted key in order to undo the application. + # Since Key2 is relative to Key1, it has to be modified to account for the change in Key1 so that its relative movement to Key1 stays the same. + # Since Key1 is relative to Key2, it has to be modified to account for the change in Key2 so that its relative movement to Key2 stays the same, but that creates an infinite loop + # + # Another way of looking at it is if Key1 moves a vertex by +1, then Key2 MUST move that same vertex by -1 since they are relative to each other + # If Key1 is applied to the basis, it should become a reverted key that moves a vertex by -1 instead so that when it's re-applied, it undoes initial application + # But that would mean that Key2 would have to become a key that moves a vertex by +1, and we want the key to keep its original relative movement of -1 + if new_basis_shapekey in keys_relative_recursive_to_new_basis: + self.report({'ERROR_INVALID_INPUT'}, t('ShapeKeyApplier.error.recursiveRelativeToLoop', name=new_basis_shapekey.name)) + return {'CANCELLED'} + + # It should work to pick a different key as a basis, so long as that key is immediately relative to itself (key.relative_key == key) + # On the off chance that old_basis_shapekey is not relative to itself, ReverseRelativeMap(mesh) has special handling that treats it as if it always is + old_basis_shapekey = mesh.data.shape_keys.key_blocks[0] + + # old_basis_shapekey will be included if it's relative to itself or if it's the first shape key, + # so it's always included in this case + keys_relative_recursive_to_old_basis = reverse_relative_map.get_relative_recursive_keys(old_basis_shapekey) + + # 0.0 would have no effect, so set to 1.0 + if new_basis_shapekey.value == 0.0: + new_basis_shapekey.value = 1.0 + + AvatarToolkit_OT_ShapeKeyApplier.apply_key_to_basis(mesh=mesh, + new_basis_shapekey=new_basis_shapekey, + keys_relative_recursive_to_new_basis=keys_relative_recursive_to_new_basis, + keys_relative_recursive_to_basis=keys_relative_recursive_to_old_basis) + + # The active key is now a key that reverts to the old relative key so rename it as such + reverted_string = ' - Reverted' + reverted_string_len = len(reverted_string) + old_name = new_basis_shapekey.name + + if new_basis_shapekey.name[-reverted_string_len:] == reverted_string: + # If the last letters of the name are the reverted_string, remove them + new_basis_shapekey.name = new_basis_shapekey.name[:-reverted_string_len] + reverted = True + else: + # Add the reverted_string to the end of the name, so it's clear that this shape key now reverts + new_basis_shapekey.name = new_basis_shapekey.name + reverted_string + reverted = False + + # Setting the value to zero will make the mesh appear unchanged in overall shape and help to show that the operator has worked correctly + new_basis_shapekey.value = 0.0 + new_basis_shapekey.slider_min = 0.0 + # Regardless of what the max was before, 1.0 will now fully undo the applied shape key + new_basis_shapekey.slider_max = 1.0 + + response_message = 'ShapeKeyApplier.successRemoved' if reverted else 'ShapeKeyApplier.successSet' + self.report({'INFO'}, t(response_message, name=old_name)) + return {'FINISHED'} + + class ReverseRelativeMap: + def __init__(self, obj): + reverse_relative_map = {} + + basis_key = obj.data.shape_keys.key_blocks[0] + for key in obj.data.shape_keys.key_blocks: + # Special handling for basis shape key to treat it as if its always relative to itself + relative_key = basis_key if key == basis_key else key.relative_key + keys_relative_to_relative_key = reverse_relative_map.get(relative_key) + if keys_relative_to_relative_key is None: + keys_relative_to_relative_key = {key} + reverse_relative_map[relative_key] = keys_relative_to_relative_key + else: + keys_relative_to_relative_key.add(key) + self.reverse_relative_map = reverse_relative_map + + # + def get_relative_recursive_keys(self, shape_key): + shape_set = set() + + # Pretty much a depth-first search, but with loop prevention + def inner_recursive_loop(key, checked_set): + # Prevent infinite loops by maintaining a set of shapes that we've checked + if key not in checked_set: + # Need to add the current key to the set of shapes we've checked before the recursive call + checked_set.add(key) + keys_relative_to_shape_key_inner = self.reverse_relative_map.get(key) + if keys_relative_to_shape_key_inner: + for relative_to_inner in keys_relative_to_shape_key_inner: + shape_set.add(relative_to_inner) + inner_recursive_loop(relative_to_inner, checked_set) + + inner_recursive_loop(shape_key, set()) + return shape_set + + @staticmethod + # Isolate the active shape key such that afterwards, creating a new shape from mix will create a shape key that at + # a value of 1.0 is the same movement as the active shape key at its current value and vertex group + # Returns a function that restores the data that got affected due to the isolation + def isolate_active_shape(obj_with_shapes): + active_shape = obj_with_shapes.active_shape_key + restore_data = {} + + # When the value is 1.0, we can simply enable show_only_shape_key on the object + if active_shape.value == 1.0: + if obj_with_shapes.show_only_shape_key: + # Don't need to do anything, it's already isolated + pass + else: + # Store the current .show_only_shape_key value, so it can be restored later + restore_data['show_only_shape_key'] = False + obj_with_shapes.show_only_shape_key = True + # When the value is not 1.0, the next simplest method is to mute all the other shapes on the object + else: + # Mute all shapes and save their current .mute value, so it can be restored later + shapekey_mutes = [] + for key_block in obj_with_shapes.data.shape_keys.key_blocks: + shapekey_mutes.append(key_block.mute) + key_block.mute = True + # Unmute the active shape key + active_shape.mute = False + + restore_data['mutes'] = shapekey_mutes + + # show_only_shape_key acts as if active_shape.value is always 1.0, so it needs to be disabled if it's enabled + if obj_with_shapes.show_only_shape_key: + # store the current value so it can be restored + restore_data['show_only_shape_key'] = True + obj_with_shapes.show_only_shape_key = False + + # closure to restore + def restore_function(): + if restore_data: + mutes = restore_data.get('mutes') + if mutes: + # Restore shape key mutes + for mute, shape in zip(mutes, obj_with_shapes.data.shape_keys.key_blocks): + shape.mute = mute + show_only_shape_key = restore_data.get('show_only_shape_key') + # show_only_shape_key can be False so need to explicitly check for None + if show_only_shape_key is not None: + # Restore show_only_shape_key + obj_with_shapes.show_only_shape_key = show_only_shape_key + + return restore_function + + # Figures out what needs to be added to each affected key, then iterates through all the affected keys, getting the current shape, + # adding the corresponding amount to it and then setting that as the new shape. + # Gets and sets shape key positions manually with foreach_get and foreach_set + # The slowest part of this function when the number of vertices increase are the shape_key.data.foreach_set() and + # shape_key.data.foreach_get() calls, so the number of calls of those should be minimised for performance + @staticmethod + def apply_key_to_basis(*, mesh, new_basis_shapekey, keys_relative_recursive_to_new_basis, keys_relative_recursive_to_basis): + data = mesh.data + num_verts = len(data.vertices) + + new_basis_shapekey_vertex_group_name = new_basis_shapekey.vertex_group + if new_basis_shapekey_vertex_group_name: + new_basis_shapekey_vertex_group = mesh.vertex_groups.get(new_basis_shapekey_vertex_group_name) + else: + new_basis_shapekey_vertex_group = None + + new_basis_affected_by_own_application = new_basis_shapekey in keys_relative_recursive_to_basis + + # Array of Vector type is flattened by foreach_get into a sequence so the length needs to be multiplied by 3 + flattened_co_length = num_verts * 3 + + # Store shape key vertex positions for new_basis + # There's no need to initialise the elements to anything since they will all be overwritten + # The ShapeKeyPoint type's 'co' property is a FloatProperty type, these are single precision floats + # It's extremely important for performance that the correct float type (np.single/np.float32) is used + # Using the wrong type could result in 3-5 times slower performance (depending on array length) due to Blender + # being required to iterate through each element in the data first instead of immediately setting/getting all + # the data directly + # See foreach_getset in bpy.rna.c of the Blender source for the implementation + new_basis_co_flat = np.empty(flattened_co_length, dtype=np.single) + new_basis_relative_co_flat = np.empty(flattened_co_length, dtype=np.single) + + new_basis_shapekey.data.foreach_get('co', new_basis_co_flat) + new_basis_shapekey.relative_key.data.foreach_get('co', new_basis_relative_co_flat) + + # This is movement of the active shape key at a value of 1.0 + difference_co_flat = np.subtract(new_basis_co_flat, new_basis_relative_co_flat) + + # Scale the difference based on the value of the active key + difference_co_flat_value_scaled = np.multiply(difference_co_flat, new_basis_shapekey.value) + + # We can reuse these arrays over and over instead of creating new ones each time + temp_co_array = np.empty(flattened_co_length, dtype=np.single) + temp_co_array2 = np.empty(flattened_co_length, dtype=np.single) + + # Scale the difference based on the vertex group of the active key + # Ideally, we would scale difference_co_flat by the weight of each vertex in new_basis_shapekey.vertex_group. + # Unfortunately, Blender has no efficient way to get all the weights for a particular vertex group, so it's + # pretty much always a few times faster to create a new shape from mix and get its 'co' with foreach_get(...) + # https://developer.blender.org/D6227 has the sort of function we're after, which could make it into Blender + # one day. + # + # For reference, the ways to get all vertex weights that you can find on stackoverflow: + # Weights from vertices: + # This scales really poorly when lots of vertices are in multiple vertex groups, especially when the vertices are not in the vertex group we want to check, + # because for every vertex v, v.groups has to be iterated until either the vertex group is found or iteration finishes without finding the vertex group + # vertex_weights = [next((g.weight for g in v.groups if g.group == vertex_group_index), 0) for v in data.vertices] + # Equivalent to: + # vertex_weights = [] + # for v in data.vertices: + # weight = 0 + # for g in v.groups: + # if g.group == vertex_group_index: + # weight = g.weight + # break + # vertex_weights.append(weight) + # + # Weights from vertex group: + # This doesn't scale poorly with lots of vertex groups like the other way does, but, if most of the vertices aren't in the vertex group, relying on catching + # the exception is really slow. If Blender had a similar method that returned a default value or even just None instead of throwing an exception, this would + # be much faster, though likely still slower than creating a new key from mix. + # Ideally we'd want a fast access method like foreach_get(...) instead of having to iterate through all the vertices individually + # vertex_weights = [] + # for i in range(num_verts): + # try: + # weight = vertex_group.weight(i) + # except: + # weight = 0 + # vertex_weights.append(weight) + if new_basis_shapekey_vertex_group: + # Need to isolate the active shape key, so that when a new shape is created from mix, it's only the active shape key + restore_function = AvatarToolkit_OT_ShapeKeyApplier.isolate_active_shape(mesh) + # This new shape key has the effect of new_basis.value and new_basis.vertex_group applied + new_basis_mixed = mesh.shape_key_add(name="temp shape (you shouldn't see this)", from_mix=True) + # Restore whatever got changed in order to isolate the active shape key + restore_function() + + # Use the temp array, new name for convenience + temp_shape_co_flat = temp_co_array + + new_basis_mixed.data.foreach_get('co', temp_shape_co_flat) + + # Often, the relative keys are the same, e.g. they're both the 'basis', but if they're not we'll need to get its data + if new_basis_mixed.relative_key == new_basis_shapekey.relative_key: + temp_shape_relative_co_flat = new_basis_relative_co_flat + else: + new_basis_mixed.relative_key.data.foreach_get('co', temp_co_array2) + temp_shape_relative_co_flat = temp_co_array2 + + difference_co_flat_scaled = np.subtract(temp_shape_co_flat, temp_shape_relative_co_flat) + + # Remove new_basis_mixed + active_index = mesh.active_shape_key_index + mesh.shape_key_remove(new_basis_mixed) + mesh.active_shape_key_index = active_index + else: + difference_co_flat_scaled = difference_co_flat_value_scaled + + if new_basis_affected_by_own_application: + # All keys in keys_recursive_relative_to_new_basis must also be in keys_recursive_relative_to_basis + # All the keys that will have only difference_co_flat_scaled added to them are those which are neither + # new_basis nor relative recursive to new_basis + keys_not_relative_recursive_to_new_basis_and_not_new_basis = (keys_relative_recursive_to_basis - keys_relative_recursive_to_new_basis) - {new_basis_shapekey} + + # This for loop is where most of the execution will happen for 'normal' setups of lots of shape keys relative to the first shape + # I looked into using multiprocessing to parallelise this, but type(key_block) and type(key_block.data) can't be pickled, + # i.e. you can't parallelise a list of either of them + # + # Add difference between new_basis_shapekey and new_basis_shapekey.relative_key (scaled according to the value and vertex_group of new_basis_shapekey) + # We already have the co array for new_basis_shapekey.relative_key, so do it separately to save a foreach_get call + new_basis_shapekey.relative_key.data.foreach_set('co', np.add(new_basis_relative_co_flat, difference_co_flat_scaled, out=temp_co_array)) + # And now the rest of the shape keys + for key_block in keys_not_relative_recursive_to_new_basis_and_not_new_basis - {new_basis_shapekey.relative_key}: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, difference_co_flat_scaled, out=temp_co_array)) + + # Shorthand key: + # NB = new_basis_shapekey + # NB.r = new_basis_shapekey.relative_key + # r(NB) = reverted(new_basis_shapekey) + # r(NB).r = reverted(new_basis_shapekey).relative_key + # NB.v = new_basis_shapekey.value + # NB.vg = new_basis_shapekey.vertex_group + # + # We need the difference between r(NB) and r(NB).r to be the negative of + # (r(NB) - r(NB).r) * NB.vg = -((NB - NB.r) * NB.v * NB.vg) + # = -(NB - NB.r) * NB.v * NB.vg + # NB.vg cancels on both sides, leaving: + # r(NB) - r(NB).r = -(NB - NB.r) * NB.v + # Rearranging for r(NB) gives: + # r(NB) = r(NB).r - (NB - NB.r) * NB.v + # Note that (NB - NB.r) * NB.v = difference_co_flat_value_scaled so: + # r(NB) = r(NB).r - difference_co_flat_value_scaled + # Note that r(NB).r = NB.r + difference_co_flat_scaled as we've added that to it + # r(NB) = NB.r + difference_co_flat_scaled - difference_co_flat_value_scaled + # Note that r(NB) = NB + X where X is what we want to find to add to NB (and all keys relative to it + # so that their relative differences remain the same) + # NB + X = NB.r + difference_co_flat_scaled - difference_co_flat_value_scaled + # X = NB.r - NB + difference_co_flat_scaled - difference_co_flat_value_scaled + # X = -(NB - NB.r) + difference_co_flat_scaled - difference_co_flat_value_scaled + # Fully expanding out would give: + # X = -(NB - NB.r) + (NB - NB.r) * NB.v * NB.vg - (NB - NB.r) * NB.v + # + # In the case of there being a vertex group, it's too costly to calculate NB.vg on its own, so we'll leave it at + # X = -(NB - NB.r) + difference_co_flat_scaled - (NB - NB.r) * NB.v + # Which we can either factor to + # X = (NB - NB.r)(-1 - NB.v) + difference_co_flat_scaled + # X = difference_co_flat * (-1 - NB.v) + difference_co_flat_scaled + # Or, as NB - NB.r = difference_co_flat, calculate as + # X = -difference_co_flat + difference_co_flat_scaled - difference_co_flat_value_scaled + # + # The numpy functions take close to a negligible amount of the total function time, so the choice isn't very + # important, however, from my own benchmarks, np.multiply(array1, scalar, out=output_array) starts to scale + # slightly better than np.add(array1, array2, out=output_array) once array1 gets to around 9000 elements or + # more + # I guess this is due to the fact that the add operation needs to do 1 extra array access per element, and + # that eventually surpasses the effect of the multiply operation being more expensive than the add + # operation + # In this case, the array length is 3*num_verts, meaning the multiplication option gets better at around + # 3000 vertices. We'll use the multiplication option + if new_basis_shapekey_vertex_group: + np.multiply(difference_co_flat, -1 - new_basis_shapekey.value, out=temp_co_array2) + np.add(temp_co_array2, difference_co_flat_scaled, out=temp_co_array2) + + # We already have the co array for new_basis_shapekey, so we can do it separately from the others to + # save a foreach_get call + new_basis_shapekey.data.foreach_set('co', np.add(new_basis_co_flat, temp_co_array2, out=temp_co_array)) + + # Now add to the rest of the keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, temp_co_array2, out=temp_co_array)) + # But for there not being a vertex group, the NB.vg term can be eliminated as it becomes effectively 1.0 + # X = -(NB - NB.r) + (NB - NB.r) * NB.v - (NB - NB.r) * NB.v + # Then the last part cancels out + # X = -(NB - NB.r) + # Giving X = -difference_co_flat + else: + # Instead of adding the difference_co_flat_scaled to each key it will be subtracted from each key instead + # We already have the co array for new_basis_shapekey, so we can do it separately to avoid a foreach_get + # Note that + # difference_co_flat = NB - NB.r + # Rearrange for NB.r + # NB.r = NB - difference_co_flat + # Instead of doing np.subtract(new_basis_co_flat, difference_co_flat) we can simply set NB to NB.r + new_basis_shapekey.data.foreach_set('co', new_basis_relative_co_flat) + # And the rest of the shape keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.subtract(temp_co_array, difference_co_flat, out=temp_co_array)) + else: + # New basis isn't relative to Basis so keys New basis is recursively relative to will remain unchanged + # Keys recursively relative to Basis and Keys recursively relative to new basis will be mutually exclusive + # Typical user setups have all the shape keys immediately relative to Basis, so this won't be used much + + # Add the difference between new_basis_shapekey and new_basis_shapekey.relative_key (scaled according to the + # value and vertex_group of new_basis_shapekey) + for key_block in keys_relative_recursive_to_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, difference_co_flat_scaled, out=temp_co_array)) + + # The difference between the reverted key and its relative key needs to equal the negative of the + # difference between new_basis and new_basis.relative_key multiplied + # new_basis.vertex_group should be present on both + # (r(NB) - r(NB).r) * NB.vg = -((NB - NB.r) * NB.v * NB.vg) + # = -(NB - NB.r) * NB.v * NB.vg + # NB.vg cancels on both sides, leaving: + # r(NB) - r(NB).r = -(NB - NB.r) * NB.v + # r(NB).r is unchanged, meaning r(NB).r = NB.r + # r(NB) - NB.r = -(NB - NB.r) * NB.v + # r(NB) = X + NB where X is what we want to find to add + # X + NB - NB.r = -(NB - NB.r) * NB.v + # Rearrange for X + # X = -(NB - NB.r) - (NB - NB.r) * NB.v + # + # (NB - NB.r) can be factorised + # X = (NB - NB.r)(-1 - NB.v) + # Note that (NB - NB.r) is difference_co_flat, giving + # X = difference_co_flat * (-1 - NB.v) + # + # Alternatively, instead of factorising, note that (NB - NB.r) * NB.v is difference_co_flat_value_scaled + # X = -(NB - NB.r) - difference_co_flat_value_scaled + # Note that (NB - NB.r) is difference_co_flat, giving + # X = -difference_co_flat - difference_co_flat_value_scaled + # Or + # X = -(difference_co_flat + difference_co_flat_value_scaled) + # + # Since NB.vg isn't present, it doesn't matter whether new_basis_shapekey has a vertex_group or not + # + # As with before, we'll use the multiplication option due to it scaling slightly better with a larger + # number of vertices + # X = difference_co_flat * (-1 - NB.v) + np.multiply(difference_co_flat, -1 - new_basis_shapekey.value, out=temp_co_array2) + + # We already have the co array for new_basis_shapekey, so we can do it separately from the others to + # save a foreach_get call + new_basis_shapekey.data.foreach_set('co', np.add(new_basis_co_flat, temp_co_array2, out=temp_co_array)) + # And now the rest of the shape keys + for key_block in keys_relative_recursive_to_new_basis: + key_block.data.foreach_get('co', temp_co_array) + key_block.data.foreach_set('co', np.add(temp_co_array, temp_co_array2, out=temp_co_array)) + + # Update mesh vertices to avoid basis shape key and mesh vertices being desynced until Edit mode has been + # entered and exited, which can cause odd behaviour when creating shape keys with from_mix=False or when + # removing all shape keys. + data.shape_keys.reference_key.data.foreach_get('co', temp_co_array) + data.vertices.foreach_set('co', temp_co_array) + + +def add_to_menu(self, context): + self.layout.separator() + self.layout.operator(AvatarToolkit_OT_ShapeKeyApplier.bl_idname, text=t('Tools.shapekey_to_basis.label'), icon="KEY_HLT") \ No newline at end of file diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index ae5de3b..e29b3ff 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -10,19 +10,13 @@ from ...core.common import ( restore_bone_transforms, remove_unused_vertex_groups, identify_bones, + duplicate_bone, + store_breaking_settings_armature, + restore_breaking_settings_armature, ) import traceback from ...core.armature_validation import validate_armature, validate_bone_hierarchy -def duplicate_bone(bone: EditBone) -> EditBone: - """Create a duplicate of the given bone""" - arm = bone.id_data - new_bone = arm.edit_bones.new(bone.name + "_copy") - new_bone.head = bone.head - new_bone.tail = bone.tail - new_bone.roll = bone.roll - new_bone.parent = bone.parent - return new_bone class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): """Operator to convert standard legs to digitigrade setup""" @@ -39,13 +33,15 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): return False valid, _, _ = validate_armature(armature) return (valid and - context.mode == 'EDIT_ARMATURE' and + (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') and context.selected_editable_bones is not None and len(context.selected_editable_bones) == 2) def process_leg_chain(self, digi0: EditBone) -> bool: """Process a single leg bone chain""" try: + bpy.ops.object.mode_set(mode='EDIT') + # Get bone chain digi1: EditBone = digi0.children[0] digi2: EditBone = digi1.children[0] @@ -83,23 +79,23 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): for bone in [digi1, digi2]: if "" not in bone.name: bone.name = bone.name.split('.')[0] + "" - return True except Exception as e: self.report({'ERROR'}, t("Tools.digitigrade_error", error=traceback.format_exc())) + return False def execute(self, context: Context) -> set[str]: """Execute the digitigrade conversion""" bpy.ops.object.mode_set(mode='EDIT') - + data_breaking = store_breaking_settings_armature(context.armature) with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress: for digi0 in context.selected_editable_bones: progress.step(t("Tools.processing_leg", bone=digi0.name)) if not self.process_leg_chain(digi0): return {'CANCELLED'} - + restore_breaking_settings_armature(context.armature, data_breaking) self.report({'INFO'}, t("Tools.digitigrade_success")) return {'FINISHED'} @@ -125,6 +121,8 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): armature = get_active_armature(context) bpy.ops.object.select_all(action='DESELECT') armature.select_set(True) + data_breaking = store_breaking_settings_armature(armature) + context.view_layer.objects.active = armature bpy.ops.object.mode_set(mode='POSE') @@ -135,6 +133,7 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): constraints_removed += 1 bpy.ops.object.mode_set(mode='OBJECT') + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) return {'FINISHED'} @@ -187,6 +186,8 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): # Store initial transforms bpy.ops.object.mode_set(mode='EDIT') initial_transforms: Dict[str, Dict[str, Any]] = {} + data_breaking = store_breaking_settings_armature(armature) + for bone in armature.data.edit_bones: initial_transforms[bone.name] = { 'head': bone.head.copy(), @@ -246,7 +247,7 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): if context.scene.avatar_toolkit.list_only_mode: self.populate_bone_list(context, zero_weight_bones) return {'FINISHED'} - + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) return {'FINISHED'} @@ -276,6 +277,7 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator): def execute(self, context: Context) -> set[str]: armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) toolkit = context.scene.avatar_toolkit selected_bones = [item.name for item in toolkit.zero_weight_bones @@ -288,7 +290,7 @@ class AvatarToolKit_OT_RemoveSelectedBones(Operator): bpy.ops.object.mode_set(mode='OBJECT') toolkit.zero_weight_bones.clear() - + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.bones_removed", count=len(selected_bones))) return {'FINISHED'} @@ -315,7 +317,8 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): def execute(self, context: Context) -> set[str]: armature = get_active_armature(context) - + data_breaking = store_breaking_settings_armature(armature) + armature_data: bpy.types.Armature = armature.data @@ -380,6 +383,7 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): #if armature.keyframe_insert(data_path=new_path, index=curve.array_index, frame=time): continue self.report({'ERROR'}, f"Keyframe insertion for key with data path \"{curve.data_path}\" and frame {time} failed!") + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} @@ -397,4 +401,5 @@ class AvatarToolKit_OT_FlipCurrentKeyFrames(Operator): # restore selection armature_data.bones.foreach_set("select", selected) + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} diff --git a/functions/tools/merge_tools.py b/functions/tools/merge_tools.py index 4078f91..837bd6f 100644 --- a/functions/tools/merge_tools.py +++ b/functions/tools/merge_tools.py @@ -4,8 +4,9 @@ from typing import Set, List from bpy.types import Operator, Context, Armature, EditBone from ...core.translations import t from ...core.logging_setup import logger -from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights +from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, store_breaking_settings_armature, restore_breaking_settings_armature from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ConnectBones(Operator): """Connect disconnected bones in chain""" @@ -23,8 +24,12 @@ class AvatarToolkit_OT_ConnectBones(Operator): return valid def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) try: - armature = get_active_armature(context) + + + logger.info("Starting bone connection operation") bpy.ops.object.mode_set(mode='EDIT') @@ -47,12 +52,14 @@ class AvatarToolkit_OT_ConnectBones(Operator): bones_connected += 1 bpy.ops.object.mode_set(mode='OBJECT') + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.connect_bones_success", count=bones_connected)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to connect bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to connect bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} class AvatarToolkit_OT_MergeToActive(Operator): @@ -67,11 +74,15 @@ class AvatarToolkit_OT_MergeToActive(Operator): armature = get_active_armature(context) if not armature: return False - return context.mode == 'EDIT_ARMATURE' and context.active_bone + return (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') and context.active_bone def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) + try: - armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') + active_bone = context.active_bone selected_bones = [b for b in context.selected_editable_bones if b != active_bone] @@ -102,11 +113,13 @@ class AvatarToolkit_OT_MergeToActive(Operator): armature.data.edit_bones.remove(bone) self.report({'INFO'}, t("Tools.merge_to_active_success", count=len(selected_bones))) + restore_breaking_settings_armature(armature, data_breaking) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to merge bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to merge bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} class AvatarToolkit_OT_MergeToParent(Operator): @@ -121,11 +134,13 @@ class AvatarToolkit_OT_MergeToParent(Operator): armature = get_active_armature(context) if not armature: return False - return context.mode == 'EDIT_ARMATURE' + return (context.mode == 'EDIT_ARMATURE' or context.mode == 'POSE') def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + data_breaking = store_breaking_settings_armature(armature) try: - armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') selected_bones = [b for b in context.selected_editable_bones if b.parent] if not selected_bones: @@ -153,10 +168,12 @@ class AvatarToolkit_OT_MergeToParent(Operator): armature.data.edit_bones.remove(bone) merged_count += 1 + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.merge_to_parent_success", count=merged_count)) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to merge bones: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to merge bones: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) + restore_breaking_settings_armature(armature, data_breaking) return {'CANCELLED'} diff --git a/functions/tools/mesh_separation.py b/functions/tools/mesh_separation.py index 96d8881..c2223c7 100644 --- a/functions/tools/mesh_separation.py +++ b/functions/tools/mesh_separation.py @@ -3,6 +3,7 @@ from bpy.types import Operator, Context from ...core.translations import t from ...core.common import get_active_armature from ...core.armature_validation import validate_armature +import traceback class AvatarToolKit_OT_SeparateByMaterials(Operator): """Operator to separate mesh by materials""" @@ -32,8 +33,8 @@ class AvatarToolKit_OT_SeparateByMaterials(Operator): bpy.ops.object.mode_set(mode='OBJECT') self.report({'INFO'}, t("Tools.separate_materials_success")) return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, str(e)) + except Exception: + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} class AvatarToolKit_OT_SeparateByLooseParts(Operator): @@ -64,6 +65,6 @@ class AvatarToolKit_OT_SeparateByLooseParts(Operator): bpy.ops.object.mode_set(mode='OBJECT') self.report({'INFO'}, t("Tools.separate_loose_success")) return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, str(e)) + except Exception: + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} diff --git a/functions/tools/rigify_converter.py b/functions/tools/rigify_converter.py index 8737454..7607b7c 100644 --- a/functions/tools/rigify_converter.py +++ b/functions/tools/rigify_converter.py @@ -6,6 +6,7 @@ from ...core.logging_setup import logger from ...core.translations import t from ...core.dictionaries import rigify_unity_names, rigify_basic_unity_names, rigify_unnecessary_bones from ...core.armature_validation import validate_armature +import traceback class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): """Convert Rigify armature to Unity-compatible format""" @@ -56,9 +57,9 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): self.report({'INFO'}, t("Tools.rigify_converted")) return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to convert Rigify: {str(e)}", exc_info=True) - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to convert Rigify: {traceback.format_exc()}", exc_info=True) + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def cleanup_extra_bones(self, armature: Object) -> None: diff --git a/functions/tools/standardize_armature.py b/functions/tools/standardize_armature.py index f7ad52a..e88558d 100644 --- a/functions/tools/standardize_armature.py +++ b/functions/tools/standardize_armature.py @@ -1,3 +1,4 @@ +import traceback import bpy import math from typing import Dict, List, Set, Tuple, Optional, Any, Union @@ -25,7 +26,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): @classmethod def poll(cls, context: Context) -> bool: armature: Optional[Object] = get_active_armature(context) - return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE'} + return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE', 'POSE'} def invoke(self, context: Context, event: Any) -> Set[str]: logger.debug("Invoking standardize armature dialog") @@ -99,20 +100,24 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): if original_mode == 'EDIT_ARMATURE': bpy.ops.object.mode_set(mode='EDIT') + if original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') return {'FINISHED'} - except Exception as e: - logger.error(f"Failed to standardize armature: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Failed to standardize armature: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) try: if original_mode == 'EDIT_ARMATURE': bpy.ops.object.mode_set(mode='EDIT') + if original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') else: bpy.ops.object.mode_set(mode='OBJECT') - except Exception as restore_error: - logger.error(f"Failed to restore original mode: {str(restore_error)}") + except Exception: + logger.error(f"Failed to restore original mode: {traceback.format_exc()}") return {'CANCELLED'} diff --git a/functions/tools/uv_tools.py b/functions/tools/uv_tools.py index 6002d73..2f5f3e5 100644 --- a/functions/tools/uv_tools.py +++ b/functions/tools/uv_tools.py @@ -6,6 +6,7 @@ import numpy as np import math from ...core.translations import t from ...core.logging_setup import logger +import traceback class GenerateLoopTreeResult(TypedDict): tree: Dict[str, Set[str]] @@ -247,8 +248,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): logger.info(f"Finished mesh {source} for UV's") - except Exception as e: - logger.error(f"Error processing source {source}: {str(e)}") + except Exception: + logger.error(f"Error processing source {source}: {traceback.format_exc()}") return {'CANCELLED'} bpy.ops.object.mode_set(mode=prev_mode) diff --git a/functions/visemes.py b/functions/visemes.py index a7365a8..418ccb1 100644 --- a/functions/visemes.py +++ b/functions/visemes.py @@ -11,6 +11,7 @@ from ..core.common import ( get_all_meshes, validate_mesh_for_pose ) +import traceback class VisemeCache: """Manages caching of generated viseme shape data for performance optimization""" @@ -211,9 +212,9 @@ class AvatarToolkit_OT_CreateVisemes(Operator): self.create_visemes(context, mesh) self.report({'INFO'}, t("Visemes.success")) return {'FINISHED'} - except Exception as e: - logger.error(f"Error creating visemes: {str(e)}") - self.report({'ERROR'}, str(e)) + except Exception: + logger.error(f"Error creating visemes: {traceback.format_exc()}") + self.report({'ERROR'}, traceback.format_exc()) return {'CANCELLED'} def create_visemes(self, context: Context, mesh: Object) -> None: diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index efc827d..e4d2136 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -228,6 +228,11 @@ "Tools.explode_mesh.distance_desc": "Scale factor for distance between exploded items on model.", "Tools.explode_mesh.split_on_seams_desc":"Split model on UV seams to separate islands from each other.", "Tools.explode_mesh.split_on_seams":"Split on Seams", + "Tools.shapekey_to_basis.label":"Apply Selected Shapekey to Basis", + "Tools.shapekey_to_basis.desc":"Applies the selected shape key to the new Basis at it's current strength and creates a reverted shape key from the selected one.", + "ShapeKeyApplier.error.recursiveRelativeToLoop":"Shapekey \"{name}\" is recursively relative to itself, so cannot be applied to the Basis", + "ShapeKeyApplier.successRemoved":"Successfully removed shapekey \"{name}\" from the Basis.", + "ShapeKeyApplier.successSet":"Successfully applied shapekey \"{name}\" to the Basis.", "Tools.apply_modifier_on_shapekey_obj":"Apply Modifier on Shapekey Object", "Tools.apply_modifier_on_shapekey_obj_desc":"Applies a modifier on an object regardless of it having shapekeys.", "Tools.merge_title": "Merge Tools", diff --git a/ui/atlas_materials_panel.py b/ui/atlas_materials_panel.py index 35e6316..2d1a31e 100644 --- a/ui/atlas_materials_panel.py +++ b/ui/atlas_materials_panel.py @@ -6,6 +6,7 @@ from ..core.common import SceneMatClass, MaterialListBool, get_active_armature from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials from ..core.translations import t from ..core.logging_setup import logger +import traceback class AvatarToolKit_OT_SelectAllMaterials(Operator): bl_idname = 'avatar_toolkit.select_all_materials' @@ -80,8 +81,8 @@ class AvatarToolKit_OT_ExpandSectionMaterials(Operator): logger.debug("Hiding material list") return {'FINISHED'} - except Exception as e: - logger.error(f"Error loading materials: {str(e)}", exc_info=True) + except Exception: + logger.error(f"Error loading materials: {traceback.format_exc()}", exc_info=True) self.report({'ERROR'}, t("TextureAtlas.load_error")) return {'CANCELLED'} From f8ef79e7cc320a8845d8a1031771fc0b4b250df1 Mon Sep 17 00:00:00 2001 From: 989onan Date: Thu, 10 Jul 2025 20:37:29 -0400 Subject: [PATCH 31/32] broke digitigrade bones, this fixes that --- functions/tools/bone_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index e29b3ff..393ad04 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -89,13 +89,13 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): def execute(self, context: Context) -> set[str]: """Execute the digitigrade conversion""" bpy.ops.object.mode_set(mode='EDIT') - data_breaking = store_breaking_settings_armature(context.armature) + data_breaking = store_breaking_settings_armature(context.active_object) with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress: for digi0 in context.selected_editable_bones: progress.step(t("Tools.processing_leg", bone=digi0.name)) if not self.process_leg_chain(digi0): return {'CANCELLED'} - restore_breaking_settings_armature(context.armature, data_breaking) + restore_breaking_settings_armature(context.active_object, data_breaking) self.report({'INFO'}, t("Tools.digitigrade_success")) return {'FINISHED'} From c055d60053955a64ebb7f63580087f085ec250b8 Mon Sep 17 00:00:00 2001 From: 989onan Date: Tue, 15 Jul 2025 17:53:10 -0400 Subject: [PATCH 32/32] Version Bump --- __init__.py | 2 +- core/common.py | 1 - functions/tools/bone_tools.py | 3 +++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 2b8b692..33e481c 100644 --- a/__init__.py +++ b/__init__.py @@ -16,7 +16,7 @@ def show_version_error_popup(): def register(): import bpy version = bpy.app.version - if version[0] > 4 or (version[0] == 4 and version[1] >= 5): + if version[0] > 5 or (version[0] == 5 and version[1] >= 3): show_version_error_popup() return diff --git a/core/common.py b/core/common.py index d1785b2..d4ea528 100644 --- a/core/common.py +++ b/core/common.py @@ -639,7 +639,6 @@ def duplicate_bone(bone: EditBone) -> EditBone: new_bone.use_connect = bone.use_connect new_bone.use_local_location = bone.use_local_location new_bone.use_inherit_rotation = bone.use_inherit_rotation - new_bone.use_inherit_scale = bone.use_inherit_scale new_bone.use_deform = bone.use_deform return new_bone diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 393ad04..0c104fd 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -55,7 +55,10 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): bpy.ops.armature.select_all(action='DESELECT') # Create and position calf bone + prev_connect = digi1.use_connect + digi1.use_connect = False calf = duplicate_bone(digi1) + digi1.use_connect = prev_connect calf.name = digi1.name.split('.')[0] calf.parent = digi0