Holy shit this was a pain

- Truly fixes PMX Import lol, i messed up completely
- Updated MMD Tools to use Cats One
This commit is contained in:
Yusarina
2025-11-19 06:35:06 +00:00
parent f0bda259d3
commit a929f68ad4
38 changed files with 4479 additions and 2709 deletions
+402 -112
View File
@@ -1,30 +1,26 @@
# -*- coding: utf-8 -*-
# Copyright 2014 MMD Tools authors
# This file was originally part of the MMD Tools add-on for Blender
# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools
# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements.
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
# Copyright 2015 MMD Tools authors
# This file is part of MMD Tools.
from typing import Optional, cast, List, Dict, Any, Set, Tuple, Union
from collections import namedtuple
from typing import Optional, cast
import bpy
from mathutils import Quaternion, Vector
from ..core.model import FnModel
from .. import bpyutils, utils
from ..core.exceptions import MaterialNotFoundError
from ..core.material import FnMaterial
from ..core.model import FnModel
from ..core.morph import FnMorph
from ..utils import ItemMoveOp, ItemOp
from ....logging_setup import logger
# Util functions
def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float]:
def divide_vector_components(vec1, vec2):
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
for v1, v2 in zip(vec1, vec2, strict=False):
if v2 == 0:
if v1 == 0:
v2 = 1 # If we have a 0/0 case we change the divisor to 1
@@ -34,17 +30,17 @@ def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float
return result
def multiply_vector_components(vec1: List[float], vec2: List[float]) -> List[float]:
def multiply_vector_components(vec1, vec2):
if len(vec1) != len(vec2):
raise ValueError("Vectors should have the same number of components")
result = []
for v1, v2 in zip(vec1, vec2):
for v1, v2 in zip(vec1, vec2, strict=False):
result.append(v1 * v2)
return result
def special_division(n1: float, n2: float) -> float:
"""This function returns 0 in case of 0/0. If non-zero divided by zero case is found, an Exception is raised"""
def special_division(n1, n2):
"""Return 0 in case of 0/0. If non-zero divided by zero case is found, an Exception is raised"""
if n2 == 0:
if n1 == 0:
n2 = 1
@@ -59,7 +55,7 @@ class AddMorph(bpy.types.Operator):
bl_description = "Add a morph item to active morph list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -69,7 +65,6 @@ class AddMorph(bpy.types.Operator):
morph.name = "New Morph"
if morph_type.startswith("uv"):
morph.data_type = "VERTEX_GROUP"
logger.debug(f"Added new morph of type {morph_type}")
return {"FINISHED"}
@@ -86,7 +81,7 @@ class RemoveMorph(bpy.types.Operator):
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -101,21 +96,19 @@ class RemoveMorph(bpy.types.Operator):
if self.all:
morphs.clear()
mmd_root.active_morph = 0
logger.debug(f"Removed all morphs of type {morph_type}")
else:
morphs.remove(mmd_root.active_morph)
mmd_root.active_morph = max(0, mmd_root.active_morph - 1)
logger.debug(f"Removed morph at index {mmd_root.active_morph} of type {morph_type}")
return {"FINISHED"}
class MoveMorph(bpy.types.Operator, ItemMoveOp):
bl_idname = "mmd_tools.morph_move"
bl_label = "Move Morph"
bl_description = "Move active morph item up/down in the list"
bl_description = "Move active morph item up/down in the list. This will not affect the morph order in exported PMX files (use Display Panel order instead)."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -124,7 +117,6 @@ class MoveMorph(bpy.types.Operator, ItemMoveOp):
mmd_root.active_morph,
self.type,
)
logger.debug(f"Moved morph to index {mmd_root.active_morph}")
return {"FINISHED"}
@@ -134,7 +126,7 @@ class CopyMorph(bpy.types.Operator):
bl_description = "Make a copy of active morph in the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -146,7 +138,7 @@ class CopyMorph(bpy.types.Operator):
if morph is None:
return {"CANCELLED"}
name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer())
name_orig, name_tmp = morph.name, f"_tmp{str(morph.as_pointer())}"
if morph_type.startswith("vertex"):
for obj in FnModel.iterate_mesh_objects(root):
@@ -161,7 +153,6 @@ class CopyMorph(bpy.types.Operator):
for k, v in morph.items():
morph_new[k] = v if k != "name" else name_tmp
morph_new.name = name_orig + "_copy" # trigger name check
logger.debug(f"Copied morph {name_orig} to {morph_new.name}")
return {"FINISHED"}
@@ -171,17 +162,14 @@ class OverwriteBoneMorphsFromActionPose(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
return root is not None and root.mmd_root.active_morph_type == "bone_morphs"
return root.mmd_root.active_morph_type == "bone_morphs"
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
root = FnModel.find_root_object(context.active_object)
FnMorph.overwrite_bone_morphs_from_action_pose(FnModel.find_armature_object(root))
logger.info("Overwrote bone morphs from active action pose")
return {"FINISHED"}
@@ -191,7 +179,7 @@ class AddMorphOffset(bpy.types.Operator):
bl_description = "Add a morph offset item to the list"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -216,7 +204,6 @@ class AddMorphOffset(bpy.types.Operator):
item.location = pose_bone.location
item.rotation = pose_bone.rotation_quaternion
logger.debug(f"Added morph offset to {morph_type}")
return {"FINISHED"}
@@ -233,7 +220,7 @@ class RemoveMorphOffset(bpy.types.Operator):
options={"SKIP_SAVE"},
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -250,21 +237,17 @@ class RemoveMorphOffset(bpy.types.Operator):
if morph_type.startswith("vertex"):
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.remove_shape_key(obj, morph.name)
logger.debug(f"Removed all vertex morph offsets for {morph.name}")
return {"FINISHED"}
elif morph_type.startswith("uv"):
if morph_type.startswith("uv"):
if morph.data_type == "VERTEX_GROUP":
for obj in FnModel.iterate_mesh_objects(root):
FnMorph.store_uv_morph_data(obj, morph)
logger.debug(f"Removed all UV morph offsets for {morph.name}")
return {"FINISHED"}
morph.data.clear()
morph.active_data = 0
logger.debug(f"Cleared all morph offsets for {morph.name}")
else:
morph.data.remove(morph.active_data)
morph.active_data = max(0, morph.active_data - 1)
logger.debug(f"Removed morph offset at index {morph.active_data}")
return {"FINISHED"}
@@ -280,7 +263,7 @@ class InitMaterialOffset(bpy.types.Operator):
default=0,
)
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -292,7 +275,6 @@ class InitMaterialOffset(bpy.types.Operator):
mat_data.specular_color = mat_data.ambient_color = (val,) * 3
mat_data.shininess = mat_data.edge_weight = val
mat_data.texture_factor = mat_data.toon_texture_factor = mat_data.sphere_texture_factor = (val,) * 4
logger.debug(f"Initialized material offset with value {val}")
return {"FINISHED"}
@@ -302,7 +284,7 @@ class ApplyMaterialOffset(bpy.types.Operator):
bl_description = "Calculates the offsets and apply them, then the temporary material is removed"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -340,7 +322,6 @@ class ApplyMaterialOffset(bpy.types.Operator):
except ZeroDivisionError:
mat_data.offset_type = "ADD" # If there is any 0 division we automatically switch it to type ADD
logger.warning("Zero division detected, switching to ADD offset type")
except ValueError:
self.report({"ERROR"}, "An unexpected error happened")
# We should stop on our tracks and re-raise the exception
@@ -358,7 +339,6 @@ class ApplyMaterialOffset(bpy.types.Operator):
mat_data.edge_weight = work_mmd_mat.edge_weight - base_mmd_mat.edge_weight
FnMaterial.clean_materials(meshObj, can_remove=lambda m: m == work_mat)
logger.info(f"Applied material offset for {mat_data.material}")
return {"FINISHED"}
@@ -368,7 +348,7 @@ class CreateWorkMaterial(bpy.types.Operator):
bl_description = "Creates a temporary material to edit this offset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -385,12 +365,12 @@ class CreateWorkMaterial(bpy.types.Operator):
base_mat = meshObj.data.materials.get(mat_data.material, None)
if base_mat is None:
self.report({"ERROR"}, 'Material "%s" not found' % mat_data.material)
self.report({"ERROR"}, f'Material "{mat_data.material}" not found')
return {"CANCELLED"}
work_mat_name = base_mat.name + "_temp"
if work_mat_name in bpy.data.materials:
self.report({"ERROR"}, 'Temporary material "%s" is in use' % work_mat_name)
self.report({"ERROR"}, f'Temporary material "{work_mat_name}" is in use')
return {"CANCELLED"}
work_mat = base_mat.copy()
@@ -427,7 +407,6 @@ class CreateWorkMaterial(bpy.types.Operator):
work_mmd_mat.edge_color = list(edge_offset)
work_mmd_mat.edge_weight += mat_data.edge_weight
logger.info(f"Created work material {work_mat_name}")
return {"FINISHED"}
@@ -437,24 +416,23 @@ class ClearTempMaterials(bpy.types.Operator):
bl_description = "Clears all the temporary materials"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
for meshObj in FnModel.iterate_mesh_objects(root):
def __pre_remove(m: Optional[bpy.types.Material]) -> bool:
def __pre_remove(m, meshObj=meshObj):
if m and "_temp" in m.name:
base_mat_name = m.name.split("_temp")[0]
try:
FnMaterial.swap_materials(meshObj, m.name, base_mat_name)
return True
except MaterialNotFoundError:
self.report({"WARNING"}, "Base material for %s was not found" % m.name)
self.report({"WARNING"}, f"Base material for {m.name} was not found")
return False
FnMaterial.clean_materials(meshObj, can_remove=__pre_remove)
logger.info("Cleared all temporary materials")
return {"FINISHED"}
@@ -464,7 +442,7 @@ class ViewBoneMorph(bpy.types.Operator):
bl_description = "View the result of active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -475,12 +453,10 @@ class ViewBoneMorph(bpy.types.Operator):
for morph_data in morph.data:
p_bone: Optional[bpy.types.PoseBone] = armature.pose.bones.get(morph_data.bone, None)
if p_bone:
# Blender 5.0: use pose bone select property directly
p_bone.select = True
mtx = (p_bone.matrix_basis.to_3x3() @ Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix()).to_4x4()
mtx.translation = p_bone.location + morph_data.location
p_bone.matrix_basis = mtx
logger.info(f"Viewing bone morph: {morph.name}")
return {"FINISHED"}
@@ -490,14 +466,13 @@ class ClearBoneMorphView(bpy.types.Operator):
bl_description = "Reset transforms of all bones to their default values"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
armature = FnModel.find_armature_object(root)
for p_bone in armature.pose.bones:
p_bone.matrix_basis.identity()
logger.info("Cleared bone morph view")
return {"FINISHED"}
@@ -507,7 +482,7 @@ class ApplyBoneMorph(bpy.types.Operator):
bl_description = "Apply current pose to active bone morph"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -522,11 +497,9 @@ class ApplyBoneMorph(bpy.types.Operator):
item.bone = p_bone.name
item.location = p_bone.location
item.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion()
# Blender 5.0: use pose bone select property directly
p_bone.select = True
else:
p_bone.select = False
logger.info(f"Applied current pose to bone morph: {morph.name}")
return {"FINISHED"}
@@ -536,7 +509,7 @@ class SelectRelatedBone(bpy.types.Operator):
bl_description = "Select the bone assigned to this offset in the armature"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -545,7 +518,6 @@ class SelectRelatedBone(bpy.types.Operator):
morph = mmd_root.bone_morphs[mmd_root.active_morph]
morph_data = morph.data[morph.active_data]
utils.selectSingleBone(context, armature, morph_data.bone)
logger.debug(f"Selected bone: {morph_data.bone}")
return {"FINISHED"}
@@ -555,7 +527,7 @@ class EditBoneOffset(bpy.types.Operator):
bl_description = "Applies the location and rotation of this offset to the bone"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -568,7 +540,6 @@ class EditBoneOffset(bpy.types.Operator):
mtx.translation = morph_data.location
p_bone.matrix_basis = mtx
utils.selectSingleBone(context, armature, p_bone.name)
logger.debug(f"Edited bone offset for {p_bone.name}")
return {"FINISHED"}
@@ -578,7 +549,7 @@ class ApplyBoneOffset(bpy.types.Operator):
bl_description = "Stores the current bone location and rotation into this offset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -590,7 +561,6 @@ class ApplyBoneOffset(bpy.types.Operator):
p_bone = armature.pose.bones[morph_data.bone]
morph_data.location = p_bone.location
morph_data.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion()
logger.debug(f"Applied bone offset for {p_bone.name}")
return {"FINISHED"}
@@ -600,7 +570,7 @@ class ViewUVMorph(bpy.types.Operator):
bl_description = "View the result of active UV morph on current mesh object"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
@@ -618,11 +588,11 @@ class ViewUVMorph(bpy.types.Operator):
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
mesh = cast("bpy.types.Mesh", meshObj.data)
morph = mmd_root.uv_morphs[mmd_root.active_morph]
uv_textures = mesh.uv_layers
base_uv_layers = [l for l in mesh.uv_layers if not l.name.startswith("_")]
base_uv_layers = [layer for layer in mesh.uv_layers if not layer.name.startswith("_")]
if morph.uv_index >= len(base_uv_layers):
self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index)
return {"CANCELLED"}
@@ -632,7 +602,7 @@ class ViewUVMorph(bpy.types.Operator):
uv_textures.active = uv_textures[uv_layer_name]
uv_layer_name = uv_textures.active.name
uv_tex = uv_textures.new(name="__uv.%s" % uv_layer_name)
uv_tex = uv_textures.new(name=f"__uv.{uv_layer_name}")
if uv_tex is None:
self.report({"ERROR"}, "Failed to create a temporary uv layer")
return {"CANCELLED"}
@@ -642,17 +612,15 @@ class ViewUVMorph(bpy.types.Operator):
if len(offsets) > 0:
base_uv_data = mesh.uv_layers.active.data
temp_uv_data = mesh.uv_layers[uv_tex.name].data
for i, l in enumerate(mesh.loops):
# Blender 5.0+: UV selection is now stored in face-corner attributes
# Skipping UV selection assignment as it's not critical for morph preview
select = l.vertex_index in offsets
for i, loop in enumerate(mesh.loops):
select = temp_uv_data[i].select = loop.vertex_index in offsets
if select:
temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index]
temp_uv_data[i].uv = base_uv_data[i].uv + offsets[loop.vertex_index]
uv_textures.active = uv_tex
uv_tex.active_render = True
meshObj.hide_set(False)
meshObj.select_set(selected)
logger.info(f"Viewing UV morph: {morph.name}")
return {"FINISHED"}
@@ -662,24 +630,24 @@ class ClearUVMorphView(bpy.types.Operator):
bl_description = "Clear all temporary data of UV morphs"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
assert root is not None
for m in FnModel.iterate_mesh_objects(root):
mesh = m.data
uv_layers = mesh.uv_layers
for t in list(uv_layers): # Create a copy to iterate safely
uv_textures = getattr(mesh, "uv_textures", mesh.uv_layers)
for t in reversed(uv_textures):
if t.name.startswith("__uv."):
uv_layers.remove(t)
if len(uv_layers) > 0:
# Only set active_index
uv_layers.active_index = 0
uv_textures.remove(t)
if len(uv_textures) > 0:
uv_textures[0].active_render = True
uv_textures.active_index = 0
animation_data = mesh.animation_data
if animation_data:
nla_tracks = animation_data.nla_tracks
for t in nla_tracks:
for t in reversed(nla_tracks):
if t.name.startswith("__uv."):
nla_tracks.remove(t)
if animation_data.action and animation_data.action.name.startswith("__uv."):
@@ -687,10 +655,9 @@ class ClearUVMorphView(bpy.types.Operator):
if animation_data.action is None and len(nla_tracks) == 0:
mesh.animation_data_clear()
for act in bpy.data.actions:
for act in reversed(bpy.data.actions):
if act.name.startswith("__uv.") and act.users < 1:
bpy.data.actions.remove(act)
logger.info("Cleared UV morph view")
return {"FINISHED"}
@@ -701,20 +668,20 @@ class EditUVMorph(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
obj = context.active_object
if obj.type != "MESH":
if obj is None or obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
meshObj = obj
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
mesh = cast("bpy.types.Mesh", meshObj.data)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_mode(type="VERT", action="ENABLE")
bpy.ops.mesh.reveal() # unhide all vertices
@@ -722,16 +689,15 @@ class EditUVMorph(bpy.types.Operator):
bpy.ops.object.mode_set(mode="OBJECT")
vertices = mesh.vertices
for l, d in zip(mesh.loops, mesh.uv_layers.active.data):
for loop, d in zip(mesh.loops, mesh.uv_layers.active.data, strict=False):
if d.select:
vertices[l.vertex_index].select = True
vertices[loop.vertex_index].select = True
polygons = mesh.polygons
polygons.active = getattr(next((p for p in polygons if all(vertices[i].select for i in p.vertices)), None), "index", polygons.active)
bpy.ops.object.mode_set(mode="EDIT")
meshObj.select_set(selected)
logger.info("Editing UV morph")
return {"FINISHED"}
@@ -742,14 +708,14 @@ class ApplyUVMorph(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
def poll(cls, context):
obj = context.active_object
if obj.type != "MESH":
if obj is None or obj.type != "MESH":
return False
active_uv_layer = obj.data.uv_layers.active
return active_uv_layer and active_uv_layer.name.startswith("__uv.")
return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.")
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
@@ -757,34 +723,31 @@ class ApplyUVMorph(bpy.types.Operator):
selected = meshObj.select_get()
with bpyutils.select_object(meshObj):
mesh = cast(bpy.types.Mesh, meshObj.data)
mesh = cast("bpy.types.Mesh", meshObj.data)
morph = mmd_root.uv_morphs[mmd_root.active_morph]
base_uv_name = mesh.uv_layers.active.name[5:]
if base_uv_name not in mesh.uv_layers:
self.report({"ERROR"}, ' * UV map "%s" not found' % base_uv_name)
self.report({"ERROR"}, f' * UV map "{base_uv_name}" not found')
return {"CANCELLED"}
base_uv_data = mesh.uv_layers[base_uv_name].data
temp_uv_data = mesh.uv_layers.active.data
axis_type = "ZW" if base_uv_name.startswith("_") else "XY"
from collections import namedtuple
__OffsetData = namedtuple("OffsetData", "index, offset")
offsets = {}
vertices = mesh.vertices
for l, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data):
if vertices[l.vertex_index].select and l.vertex_index not in offsets:
for loop, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data, strict=False):
if vertices[loop.vertex_index].select and loop.vertex_index not in offsets:
dx, dy = i1.uv - i0.uv
if abs(dx) > 0.0001 or abs(dy) > 0.0001:
offsets[l.vertex_index] = __OffsetData(l.vertex_index, (dx, dy, dx, dy))
offsets[loop.vertex_index] = __OffsetData(loop.vertex_index, (dx, dy, dx, dy))
FnMorph.store_uv_morph_data(meshObj, morph, offsets.values(), axis_type)
morph.data_type = "VERTEX_GROUP"
meshObj.select_set(selected)
logger.info(f"Applied UV morph: {morph.name}")
return {"FINISHED"}
@@ -795,12 +758,339 @@ class CleanDuplicatedMaterialMorphs(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return FnModel.find_root_object(context.active_object) is not None
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
return root is not None
def execute(self, context: bpy.types.Context) -> Set[str]:
def execute(self, context: bpy.types.Context):
mmd_root_object = FnModel.find_root_object(context.active_object)
FnMorph.clean_duplicated_material_morphs(mmd_root_object)
logger.info("Cleaned duplicated material morphs")
return {"FINISHED"}
class ConvertBoneMorphToVertexMorph(bpy.types.Operator):
bl_idname = "mmd_tools.convert_bone_morph_to_vertex_morph"
bl_label = "Convert To Vertex Morph"
bl_description = "Convert a bone morph into a single vertex morph by applying the bone transformations.\nIf a corresponding vertex morph already exists, it will be updated."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
mmd_root = root.mmd_root
if mmd_root.active_morph_type != "bone_morphs":
return False
morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph)
return morph is not None and len(morph.data) > 0
def execute(self, context):
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
# Get the active bone morph
bone_morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph)
if bone_morph is None:
self.report({"ERROR"}, "No active bone morph")
return {"CANCELLED"}
original_name = bone_morph.name
target_name = original_name
# Add 'B' suffix if necessary
if not original_name.endswith("B"):
bone_morph.name = original_name + "B"
target_name = original_name
else:
# If already has B suffix, use name without B
target_name = original_name[:-1]
try:
# Step 1: import
from ..core.model import Model
rig = Model(root)
# Ensure morph slider is bound
bpy.ops.mmd_tools.morph_slider_setup(type="BIND")
# Re-obtain placeholder object
placeholder_obj = rig.morph_slider.placeholder()
if placeholder_obj is None or placeholder_obj.data.shape_keys is None:
self.report({"ERROR"}, "Failed to create morph slider system")
return {"CANCELLED"}
shape_keys = placeholder_obj.data.shape_keys
key_blocks = shape_keys.key_blocks
# Step 2: Check if target bone morph exists
current_morph_name = bone_morph.name
if current_morph_name not in key_blocks:
self.report({"ERROR"}, f"Bone morph '{current_morph_name}' not found in morph sliders")
return {"CANCELLED"}
# Step 3: Save all current morph values
original_values = {}
for key_block in key_blocks:
if key_block.name != "--- morph sliders ---":
original_values[key_block.name] = key_block.value
# Step 4: Set all morphs to 0
for key_block in key_blocks:
if key_block.name != "--- morph sliders ---":
key_block.value = 0
# Step 5: Set target bone morph to 1.0
key_blocks[current_morph_name].value = 1.0
# Step 6: Use Armature Modifier's "Apply as Shape Key" functionality
created_shape_keys = []
for mesh_obj in FnModel.iterate_mesh_objects(root):
# Switch to this mesh object
context.view_layer.objects.active = mesh_obj
# Ensure mesh object has shape keys
if mesh_obj.data.shape_keys is None:
mesh_obj.shape_key_add(name="Basis", from_mix=False)
# Delete existing shape key with same name
if target_name in mesh_obj.data.shape_keys.key_blocks:
idx = mesh_obj.data.shape_keys.key_blocks.find(target_name)
if idx >= 0:
mesh_obj.active_shape_key_index = idx
bpy.ops.object.shape_key_remove()
# Find armature modifier
armature_modifier = None
for modifier in mesh_obj.modifiers:
if modifier.type == "ARMATURE":
armature_modifier = modifier
break
if armature_modifier is None:
self.report({"WARNING"}, f"No armature modifier found on mesh '{mesh_obj.name}'")
continue
# Use Apply as Shape Key functionality, keeping the modifier
bpy.ops.object.modifier_apply_as_shapekey(modifier=armature_modifier.name, keep_modifier=True)
# Rename the newly created shape key to target name
shape_key_blocks = mesh_obj.data.shape_keys.key_blocks
new_shape_key = shape_key_blocks[-1] # Latest created shape key
new_shape_key.name = target_name
new_shape_key.value = 0.0 # Set to 0 to avoid double effect
created_shape_keys.append((mesh_obj.name, target_name))
self.report({"INFO"}, f"Created shape key '{target_name}' on mesh '{mesh_obj.name}'")
# Step 7: Restore all original morph values
for key_name, original_value in original_values.items():
if key_name in key_blocks:
key_blocks[key_name].value = original_value
# Step 8: Create or update vertex morph entry
vertex_morph_exists = False
for i, morph in enumerate(mmd_root.vertex_morphs):
if morph.name == target_name:
vertex_morph_exists = True
mmd_root.active_morph_type = "vertex_morphs"
mmd_root.active_morph = i
break
if not vertex_morph_exists:
mmd_root.active_morph_type = "vertex_morphs"
morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph)
morph.name = target_name
# Step 9: Add to facial expression display frame
facial_frame = None
for frame in mmd_root.display_item_frames:
if frame.name == "表情":
facial_frame = frame
break
if facial_frame:
morph_exists_in_frame = False
for item in facial_frame.data:
if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs":
morph_exists_in_frame = True
break
if not morph_exists_in_frame:
new_item = facial_frame.data.add()
new_item.type = "MORPH"
new_item.morph_type = "vertex_morphs"
new_item.name = target_name
facial_frame.active_item = len(facial_frame.data) - 1
for i, frame in enumerate(mmd_root.display_item_frames):
if frame.name == "表情":
mmd_root.active_display_item_frame = i
break
# UNBIND
bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND")
# Success message
shape_key_info = ", ".join([f"{mesh}:{key}" for mesh, key in created_shape_keys])
self.report({"INFO"}, f"Successfully converted bone morph '{original_name}' to vertex morph '{target_name}'. Created shape keys: {shape_key_info}")
except Exception as e:
self.report({"ERROR"}, f"Error during conversion: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
class ConvertGroupMorphToVertexMorph(bpy.types.Operator):
bl_idname = "mmd_tools.convert_group_morph_to_vertex_morph"
bl_label = "Convert To Vertex Morph"
bl_description = "Convert a group morph into a single vertex morph by merging only the vertex morphs within the group.\nIf a corresponding vertex morph already exists, it will be updated."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
root = FnModel.find_root_object(context.active_object)
if root is None:
return False
mmd_root = root.mmd_root
if mmd_root.active_morph_type != "group_morphs":
return False
morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph)
return morph is not None and len(morph.data) > 0
def execute(self, context):
bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND")
obj = context.active_object
root = FnModel.find_root_object(obj)
mmd_root = root.mmd_root
# Get the active group morph
group_morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph)
if group_morph is None:
self.report({"ERROR"}, "No active group morph")
return {"CANCELLED"}
# Check if the group morph contains any vertex morphs to convert
has_vertex_morphs = False
for offset in group_morph.data:
if offset.morph_type == "vertex_morphs":
has_vertex_morphs = True
break
if not has_vertex_morphs:
self.report({"ERROR"}, "The group morph does not contain any vertex morphs to convert")
return {"CANCELLED"}
original_name = group_morph.name
target_name = original_name
# Add 'G' suffix if necessary
if not original_name.endswith("G"):
group_morph.name = original_name + "G"
target_name = original_name
else:
# If already has G suffix, use name without G
target_name = original_name[:-1]
# First, reset all shape keys to zero
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.value = 0
# Apply only the vertex morphs from the group morph
for offset in group_morph.data:
if offset.morph_type == "vertex_morphs":
# Find the vertex morph by name
vertex_morph = getattr(root.mmd_root, offset.morph_type).get(offset.name)
if vertex_morph:
# Apply this morph at the specified factor
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
kb = obj.data.shape_keys.key_blocks.get(offset.name)
if kb:
kb.value = offset.factor
# Now add a new shape key from mix for each mesh
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
# Make this the active object
context.view_layer.objects.active = obj
# Remove existing shape key if it exists
if target_name in obj.data.shape_keys.key_blocks:
idx = obj.data.shape_keys.key_blocks.find(target_name)
if idx >= 0:
obj.active_shape_key_index = idx
bpy.ops.object.shape_key_remove()
# Add shape key from mix
bpy.ops.object.shape_key_add(from_mix=True)
# Rename the newly created shape key
new_key = obj.data.shape_keys.key_blocks[-1]
new_key.name = target_name
# Check if a vertex morph with the target name already exists
vertex_morph_exists = False
for i, morph in enumerate(mmd_root.vertex_morphs):
if morph.name == target_name:
vertex_morph_exists = True
mmd_root.active_morph_type = "vertex_morphs"
mmd_root.active_morph = i
break
# If not, create a new vertex morph
if not vertex_morph_exists:
# Switch to vertex morphs panel
mmd_root.active_morph_type = "vertex_morphs"
# Add new vertex morph
morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph)
morph.name = target_name
# Add the new vertex morph to the facial display frame
facial_frame = None
for frame in mmd_root.display_item_frames:
if frame.name == "表情": # This is the facial display frame
facial_frame = frame
break
if facial_frame:
# Check if this morph is already in the facial frame
morph_exists_in_frame = False
for item in facial_frame.data:
if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs":
morph_exists_in_frame = True
break
# If not, add it
if not morph_exists_in_frame:
new_item = facial_frame.data.add()
new_item.type = "MORPH"
new_item.morph_type = "vertex_morphs"
new_item.name = target_name
# Make this the active item in the facial frame
facial_frame.active_item = len(facial_frame.data) - 1
# Set the facial frame as active
for i, frame in enumerate(mmd_root.display_item_frames):
if frame.name == "表情":
mmd_root.active_display_item_frame = i
break
# Reset all shape keys
for obj in FnModel.iterate_mesh_objects(root):
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.value = 0
self.report({"INFO"}, f"Successfully converted vertex morphs in group to vertex morph '{target_name}' and added to facial display frame")
return {"FINISHED"}