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