Files
Avatar-Toolkit/core/importers/pmx/importer.py
T
Yusarina 3414ad8917 Initial MMD Importer Commit
- This is the initial commit I spent several hours trying to get it up two Avatar Toolkit standard, it does not work yet because there are files missing but I been doing this since 6am and it is 4pm almost, i need food.
- I have also removed as much legacy code as i could, MMD Tools contains so much of it even though there have a 4.2+ only version there have not removed any of the legacy code for pre 4.2.... this is going to take a while.

God I hope this works fine once I am done.
2025-04-03 15:39:03 +01:00

1076 lines
49 KiB
Python

# -*- 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)