# Copyright 2022 MMD Tools authors # This file is part of MMD Tools. import itertools from operator import itemgetter from typing import Dict, List, Optional, Set import bmesh import bpy import numpy as np from mathutils import Matrix from ..bpyutils import FnContext, select_object from ..core.model import FnModel, Model class NoModelSelectedError(Exception): """Raised when no MMD model is selected.""" class ModelJoinByBonesOperator(bpy.types.Operator): bl_idname = "mmd_tools.model_join_by_bones" bl_label = "Model Join by Bones" bl_description = "Join multiple MMD models into one.\n\nWARNING: To align models before joining, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the models to be in a clean state." bl_options = {"REGISTER", "UNDO"} join_type: bpy.props.EnumProperty( 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 NoModelSelectedError 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 NoModelSelectedError("No MMD Models selected") # Save original active_layer_collection orig_active_layer_collection = context.view_layer.active_layer_collection # Find layer collection containing parent_root_object and set it as active layer_collection = FnContext.find_user_layer_collection_by_object(context, parent_root_object) if layer_collection: context.view_layer.active_layer_collection = layer_collection # Execute the join operation FnModel.join_models(parent_root_object, child_root_objects) # Restore original active_layer_collection context.view_layer.active_layer_collection = orig_active_layer_collection bpy.ops.object.mode_set(mode="OBJECT") parent_armature_object = FnModel.find_armature_object(parent_root_object) FnContext.set_active_and_select_single_object(context, parent_armature_object) bpy.ops.object.mode_set(mode="EDIT") bpy.ops.armature.parent_set(type="OFFSET") # Connect child bones if self.join_type == "CONNECTED": parent_edit_bone: 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_description = "Separate MMD model into multiple models based on selected bones.\n\nWARNING: This operation will split meshes, armatures, rigid bodies and joints. To move models before separating, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly before separating as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the model to be in a clean state." bl_options = {"REGISTER", "UNDO"} separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True) 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 NoModelSelectedError 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: original_active_bone = context.active_bone for edit_bone in root_bones: context.active_object.data.edit_bones.active = edit_bone bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1) self._select_related_ik_bones(target_armature_object) if original_active_bone: context.active_object.data.edit_bones.active = original_active_bone separate_bones: Dict[str, 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()) bpy.ops.object.mode_set(mode="OBJECT") # Store original transform matrix for root object original_matrix_world = mmd_root_object.matrix_world.copy() mmd_root_object.matrix_world = Matrix.Identity(4) # Reset object visibility FnContext.set_active_and_select_single_object(context, mmd_root_object) bpy.ops.mmd_tools.reset_object_visibility() # Clean additional transform FnContext.set_active_and_select_single_object(context, mmd_root_object) bpy.ops.mmd_tools.clean_additional_transform() # Create new separate model first separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, obj_name=mmd_root_object.name, add_root_bone=False) separate_model.initialDisplayFrames() separate_root_object = separate_model.rootObject() separate_root_object.matrix_world = mmd_root_object.matrix_world separate_model_armature_object = separate_model.armature() # Now separate armature bones from original model separate_armature_object: Optional[bpy.types.Object] = None if self.separate_armature: FnContext.set_active_and_select_single_object(context, target_armature_object) bpy.ops.object.mode_set(mode="EDIT") # Re-select the bones that should be separated (they might have been deselected) for bone_name in separate_bones.keys(): if bone_name in target_armature_object.data.edit_bones: target_armature_object.data.edit_bones[bone_name].select = True bpy.ops.armature.separate() separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object and a.type == "ARMATURE"]), None) bpy.ops.object.mode_set(mode="OBJECT") # Collect separate rigid bodies separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones} boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all # Collect separate joints separate_joints: Set[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: List[bpy.types.Object] = [] model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] = {} if len(mmd_model_mesh_objects) > 0: # Find a single unique attribute name that doesn't conflict with any existing attributes. all_attribute_names = {attr.name for obj in mmd_model_mesh_objects for attr in obj.data.attributes} temp_normal_name = "mmd_temp_normal" i = 0 while temp_normal_name in all_attribute_names: temp_normal_name = f"mmd_temp_normal.{i:03d}" i += 1 # Backup custom normals to the unique temporary attribute. for mesh_obj in mmd_model_mesh_objects: mesh_data = mesh_obj.data existing_custom_normal = mesh_data.attributes.get("custom_normal") if not existing_custom_normal: continue if existing_custom_normal.data_type == "INT16_2D": normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16) existing_custom_normal.data.foreach_get("value", normals_data) temp_normal_attr = mesh_data.attributes.new(temp_normal_name, "INT16_2D", "CORNER") temp_normal_attr.data.foreach_set("value", normals_data) else: raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'") # Select meshes obj: bpy.types.Object for obj in context.view_layer.objects: obj.select_set(obj in mmd_model_mesh_objects) context.view_layer.objects.active = mmd_model_mesh_objects[0] # Separate mesh by selected vertices bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.separate(type="SELECTED") separate_mesh_objects = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects] bpy.ops.object.mode_set(mode="OBJECT") model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects, strict=False)) # Restore normal data for all meshes (original and separated) all_mesh_objects = list(mmd_model_mesh_objects) + list(separate_mesh_objects) for mesh_obj in all_mesh_objects: mesh_data = mesh_obj.data temp_normal_attr = mesh_data.attributes.get(temp_normal_name) if not temp_normal_attr: continue try: if temp_normal_attr.data_type == "INT16_2D": normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16) temp_normal_attr.data.foreach_get("value", normals_data) custom_normal_attr = mesh_data.attributes.get("custom_normal") if not custom_normal_attr: custom_normal_attr = mesh_data.attributes.new("custom_normal", "INT16_2D", "CORNER") custom_normal_attr.data.foreach_set("value", normals_data) else: raise TypeError(f"Unsupported custom_normal data type: '{temp_normal_attr.data_type}'. Supported types: 'INT16_2D'") finally: mesh_data.attributes.remove(temp_normal_attr) if self.separate_armature and separate_armature_object: separate_armature_data = separate_armature_object.data with select_object(separate_model_armature_object, objects=[separate_model_armature_object, separate_armature_object]): bpy.ops.object.join() if separate_armature_data.users == 0: bpy.data.armatures.remove(separate_armature_data) if separate_mesh_objects: with select_object(separate_model_armature_object, objects=[separate_model_armature_object] + separate_mesh_objects): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) # Replace mesh armature modifier.object 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_armature", "ARMATURE") armature_modifier.object = separate_model_armature_object if separate_rigid_bodies: with select_object(separate_model.rigidGroupObject(), objects=[separate_model.rigidGroupObject()] + list(separate_rigid_bodies)): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) if separate_joints: with select_object(separate_model.jointGroupObject(), objects=[separate_model.jointGroupObject()] + list(separate_joints)): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) # Move separate objects to new collection 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): if separate_object.name not in separate_layer_collection.collection.objects: separate_layer_collection.collection.objects.link(separate_object) if separate_object.name in mmd_layer_collection.collection.objects: mmd_layer_collection.collection.objects.unlink(separate_object) 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()}, }, ) # Apply additional transform FnContext.set_active_and_select_single_object(context, mmd_root_object) bpy.ops.mmd_tools.apply_additional_transform() FnContext.set_active_and_select_single_object(context, separate_root_object) bpy.ops.mmd_tools.apply_additional_transform() # Restore original transform matrix for root object mmd_root_object.matrix_world = original_matrix_world separate_root_object.matrix_world = original_matrix_world # End state FnContext.set_active_and_select_single_object(context, separate_root_object) def _select_weighted_vertices(self, mmd_model_mesh_objects: List[bpy.types.Object], separate_bones: Dict[str, bpy.types.EditBone], deform_bones: Dict[str, bpy.types.EditBone], weight_threshold: float) -> Dict[bpy.types.Object, int]: mesh2selected_vertex_count: Dict[bpy.types.Object, int] = {} target_bmesh: bmesh.types.BMesh = bmesh.new() for mesh_object in mmd_model_mesh_objects: vertex_groups: bpy.types.VertexGroups = mesh_object.vertex_groups mesh: 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 def _select_related_ik_bones(self, armature_object: bpy.types.Object) -> None: """ Expand the current selection to include any full IK systems that are partially selected. An IK system includes the chain bones, the IK target bone, and the pole target bone. NOTE: This method operates entirely in EDIT mode and avoids mode switching to prevent segmentation faults. """ edit_bones = armature_object.data.edit_bones initial_selection_names = {b.name for b in edit_bones if b.select} # Access pose bones constraints directly without mode switching pose_bones = armature_object.pose.bones # Find all complete IK systems ik_systems = [] for pose_bone in pose_bones: for constraint in pose_bone.constraints: if constraint.type == "IK": # Build the set of bones in this IK system system_bones = {pose_bone.name} # Add the main IK Target bone if constraint.target and constraint.subtarget: system_bones.add(constraint.subtarget) # Add the Pole Target bone if constraint.pole_target and constraint.pole_subtarget: system_bones.add(constraint.pole_subtarget) # Add all other bones in the IK chain current_bone_name = pose_bone.name chain_count = constraint.chain_count # Walk up the parent chain for _ in range(chain_count - 1): if current_bone_name not in edit_bones: break current_bone = edit_bones[current_bone_name] if not current_bone.parent: break current_bone_name = current_bone.parent.name system_bones.add(current_bone_name) ik_systems.append(system_bones) # Expand selection to include any related, full IK systems final_selection_names = set(initial_selection_names) for system in ik_systems: if not system.isdisjoint(initial_selection_names): final_selection_names.update(system) # Apply the final selection for bone in edit_bones: bone.select = bone.name in final_selection_names