# Copyright 2014 MMD Tools authors # This file is part of MMD Tools. from ....core.logging_setup import logger import os import re import time import traceback import bpy from bpy.types import Operator, OperatorFileListElement from bpy_extras.io_utils import ExportHelper, ImportHelper # from .. import auto_scene_setup # Not used in Avatar Toolkit from ..core.camera import MMDCamera from ..core.lamp import MMDLamp from ..core.model import FnModel, Model # from ..core.pmd import importer as pmd_importer # PMD not supported in Avatar Toolkit # from ..core.pmx import exporter as pmx_exporter # PMX export not supported in Avatar Toolkit from ..core.pmx import importer as pmx_importer # from ..core.vmd import exporter as vmd_exporter # VMD export not supported in Avatar Toolkit from ..core.vmd import importer as vmd_importer # from ..core.vpd import exporter as vpd_exporter # VPD not supported in Avatar Toolkit # from ..core.vpd import importer as vpd_importer # VPD not supported in Avatar Toolkit from ..translations import DictionaryEnum from ..utils import makePmxBoneMap LOG_LEVEL_ITEMS = [ ("DEBUG", "4. DEBUG", "", 1), ("INFO", "3. INFO", "", 2), ("WARNING", "2. WARNING", "", 3), ("ERROR", "1. ERROR", "", 4), ] def log_handler(log_level, filepath=None): if filepath is None: handler = logger.StreamHandler() else: handler = logger.FileHandler(filepath, mode="w", encoding="utf-8") formatter = logger.Formatter("%(message)s") handler.setFormatter(formatter) return handler def _update_types(cls, prop): types = cls.types.copy() if "PHYSICS" in types: types.add("ARMATURE") if "DISPLAY" in types: types.add("ARMATURE") if "MORPHS" in types: types.add("ARMATURE") types.add("MESH") if types != cls.types: cls.types = types # trigger update def get_addon_package_name(): """Get the root package name for addon preferences""" current_package = __package__ parts = current_package.split(".") try: index = parts.index("mmd_tools_local") return ".".join(parts[: index + 1]) except ValueError: pass return current_package def get_preset_directories(operator_bl_idname): """Get preset directories for an operator""" preset_dirs = [] try: # Try the official API first official_dirs = bpy.utils.preset_paths(operator_bl_idname) preset_dirs.extend(official_dirs) # Add manual preset paths as fallback scripts_dir = bpy.utils.user_resource("SCRIPTS") config_dir = bpy.utils.user_resource("CONFIG") manual_preset_paths = [ os.path.join(scripts_dir, "presets", "operator", operator_bl_idname), os.path.join(config_dir, "presets", "operator", operator_bl_idname), ] for path in manual_preset_paths: if os.path.exists(path) and path not in preset_dirs: preset_dirs.append(path) except Exception: pass return preset_dirs def apply_operator_preset(operator, preset_name): """Apply a saved preset to an operator instance""" if not preset_name: return False try: preset_dirs = get_preset_directories(operator.__class__.bl_idname) if not preset_dirs: return False # Look for the preset file preset_file = None for path in preset_dirs: potential_file = os.path.join(path, preset_name + ".py") if os.path.exists(potential_file): preset_file = potential_file break if not preset_file: return False # Execute preset with proper context with bpy.context.temp_override(active_operator=operator): try: with open(preset_file, encoding="utf-8") as f: preset_code = f.read() namespace = {"bpy": bpy} exec(preset_code, namespace) return True except Exception: return False except Exception: return False def get_available_presets(operator_bl_idname): """Get list of available presets for an operator""" presets = [] try: preset_dirs = get_preset_directories(operator_bl_idname) for preset_dir in preset_dirs: try: for filename in os.listdir(preset_dir): if filename.endswith(".py"): preset_name = filename[:-3] # Remove .py extension if preset_name not in presets: presets.append(preset_name) except Exception: continue return sorted(presets) except Exception: return [] def load_default_settings_from_preferences(operator, context, preset_property_name): """Load default settings from preferences using preset""" try: addon_package = get_addon_package_name() addon_prefs = context.preferences.addons.get(addon_package) if not addon_prefs: return False prefs = addon_prefs.preferences # Check if the preset property exists if not hasattr(prefs, preset_property_name): return False # Apply preset if specified preset_name = getattr(prefs, preset_property_name, "") if preset_name and apply_operator_preset(operator, preset_name): return True return False except Exception: return False def get_armature_display_items(self, context): # https://docs.blender.org/api/current/bpy.props.html#bpy.props.EnumProperty # self & context are required, even though they are not used in function enum_items = bpy.types.Armature.bl_rna.properties["display_type"].enum_items return [(item.identifier, item.name, "") for item in enum_items] class PreferencesMixin: """Mixin for operators that load default settings from preferences""" _preferences_applied = False def load_preferences_on_invoke(self, context, preset_property_name): """Load preferences on first invoke""" self._preferences_were_applied = getattr(self.__class__, "_preferences_applied", False) if not self._preferences_were_applied: if load_default_settings_from_preferences(self, context, preset_property_name): self.__class__._preferences_applied = True def restore_preferences_on_cancel(self): """Restore preferences state on cancel""" self.__class__._preferences_applied = self._preferences_were_applied class ImportPmx(Operator, ImportHelper, PreferencesMixin): bl_idname = "mmd_tools.import_model" bl_label = "Import Model File (.pmd, .pmx)" bl_description = "Import model file(s) (.pmd, .pmx)" bl_options = {"REGISTER", "UNDO", "PRESET"} files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) filename_ext = ".pmx" filter_glob: bpy.props.StringProperty(default="*.pmx;*.pmd", options={"HIDDEN"}) types: bpy.props.EnumProperty( name="Types", description="Select which parts will be imported", options={"ENUM_FLAG"}, items=[ ("MESH", "Mesh", "Mesh", 1), ("ARMATURE", "Armature", "Armature", 2), ("PHYSICS", "Physics", "Rigidbodies and joints (include Armature)", 4), ("DISPLAY", "Display", "Display frames (include Armature)", 8), ("MORPHS", "Morphs", "Morphs (include Armature and Mesh)", 16), ], default={ "MESH", "ARMATURE", "PHYSICS", "DISPLAY", "MORPHS", }, update=_update_types, ) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for importing the model", default=0.08, ) clean_model: bpy.props.BoolProperty( name="Clean Model", description="Remove unused vertices and duplicated/invalid faces", default=True, ) remove_doubles: bpy.props.BoolProperty( name="Remove Doubles", description="Merge duplicated vertices and faces.\nWarning: This will perform global vertex merging instead of per-material vertex merging which may break mesh geometry, material boundaries, and distort the UV map. Use with caution.", default=False, ) import_adduv2_as_vertex_colors: bpy.props.BoolProperty( name="Import Vertex Colors", description="Import ADD UV2 data as vertex colors. When enabled, the UV2 layer will still be created.", default=False, ) fix_bone_order: bpy.props.BoolProperty( name="Fix Bone Order", description="Automatically fix bone order after import. This ensures bones are ordered correctly for MMD compatibility.", default=True, ) fix_ik_links: bpy.props.BoolProperty( name="Fix IK Links", description="Fix IK links to be blender suitable", default=False, ) ik_loop_factor: bpy.props.IntProperty( name="IK Loop Factor", description="Scaling factor of MMD IK loop", min=1, soft_max=10, max=100, default=5, ) apply_bone_fixed_axis: bpy.props.BoolProperty( name="Apply Bone Fixed Axis", description="Apply bone's fixed axis to be blender suitable", default=False, ) rename_bones: bpy.props.BoolProperty( name="Rename Bones - L / R Suffix", description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", default=True, ) use_underscore: bpy.props.BoolProperty( name="Rename Bones - Use Underscore", description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", default=False, ) dictionary: bpy.props.EnumProperty( name="Rename Bones To English", items=DictionaryEnum.get_dictionary_items, description="Translate bone names from Japanese to English using selected dictionary", ) bone_disp_mode: bpy.props.EnumProperty( name="Bone Display Mode", items=get_armature_display_items, description="Change how bones look in viewport.", ) use_mipmap: bpy.props.BoolProperty( name="use MIP maps for UV textures", description="Specify if mipmaps will be generated", default=True, ) sph_blend_factor: bpy.props.FloatProperty( name="influence of .sph textures", description="The diffuse color factor of texture slot for .sph textures", default=1.0, ) spa_blend_factor: bpy.props.FloatProperty( name="influence of .spa textures", description="The diffuse color factor of texture slot for .spa textures", default=1.0, ) add_rigid_body_world: bpy.props.BoolProperty( name="Add Rigid Body World", description="Automatically add Rigid Body World to the scene when importing physics.", default=True, ) log_level: bpy.props.EnumProperty( name="Log level", description="Select log level", items=LOG_LEVEL_ITEMS, default="INFO", ) save_log: bpy.props.BoolProperty( name="Create a log file", description="Create a log file", default=False, ) def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_pmx_import_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def execute(self, context): try: self.__translator = DictionaryEnum.get_translator(self.dictionary) if self.directory: for f in self.files: self.filepath = os.path.join(self.directory, f.name) self._do_execute(context) elif self.filepath: self._do_execute(context) except Exception: logger.exception("Error occurred") err_msg = traceback.format_exc() self.report({"ERROR"}, err_msg) return {"FINISHED"} def _do_execute(self, context): # Avatar Toolkit uses its own logger system, skip log configuration # logger level and handlers are managed globally try: importer_cls = pmx_importer.PMXImporter if re.search(r"\.pmd$", self.filepath, flags=re.IGNORECASE): # PMD format not supported in Avatar Toolkit self.report({"ERROR"}, "PMD format is not supported. Please convert to PMX format.") return {"CANCELLED"} importer_cls().execute( filepath=self.filepath, types=self.types, scale=self.scale, clean_model=self.clean_model, remove_doubles=self.remove_doubles, import_adduv2_as_vertex_colors=self.import_adduv2_as_vertex_colors, fix_bone_order=self.fix_bone_order, fix_ik_links=self.fix_ik_links, ik_loop_factor=self.ik_loop_factor, apply_bone_fixed_axis=self.apply_bone_fixed_axis, rename_LR_bones=self.rename_bones, use_underscore=self.use_underscore, bone_disp_mode=self.bone_disp_mode, translator=self.__translator, use_mipmap=self.use_mipmap, sph_blend_factor=self.sph_blend_factor, spa_blend_factor=self.spa_blend_factor, add_rigid_body_world=self.add_rigid_body_world, ) self.report({"INFO"}, f'Imported MMD model from "{self.filepath}"') except Exception: logger.exception("Error occurred") raise return {"FINISHED"} class ImportVmd(Operator, ImportHelper, PreferencesMixin): bl_idname = "mmd_tools.import_vmd" bl_label = "Import VMD File (.vmd)" bl_description = "Import a VMD file to selected objects (.vmd)\nBehavior varies depending on the selected object:\n- Select the root (cross under the model): imports both armature and morph animations\n- Select the model: imports only morph animation\n- Select the armature: imports only armature animation" bl_options = {"REGISTER", "UNDO", "PRESET"} files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) filename_ext = ".vmd" filter_glob: bpy.props.StringProperty(default="*.vmd", options={"HIDDEN"}) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for importing the motion", default=0.08, ) margin: bpy.props.IntProperty( name="Margin", description="Number of frames to add before the motion starts (only applies if current frame is 0 or 1)", min=0, default=0, ) bone_mapper: bpy.props.EnumProperty( name="Bone Mapper", description="Select bone mapper", items=[ ("BLENDER", "Blender", "Use blender bone name", 0), ("PMX", "PMX", "Use japanese name of MMD bone", 1), ("RENAMED_BONES", "Renamed bones", "Rename the bone of motion data to be blender suitable", 2), ], default="PMX", ) rename_bones: bpy.props.BoolProperty( name="Rename Bones - L / R Suffix", description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", default=True, ) use_underscore: bpy.props.BoolProperty( name="Rename Bones - Use Underscore", description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", default=False, ) dictionary: bpy.props.EnumProperty( name="Rename Bones To English", items=DictionaryEnum.get_dictionary_items, description="Translate bone names from Japanese to English using selected dictionary", ) use_pose_mode: bpy.props.BoolProperty( name="Treat Current Pose as Rest Pose", description="You can pose the model to fit the original pose of a motion data, such as T-Pose or A-Pose", default=False, options={"SKIP_SAVE"}, ) use_mirror: bpy.props.BoolProperty( name="Mirror Motion", description="Import the motion by using X-Axis mirror", default=False, ) update_scene_settings: bpy.props.BoolProperty( name="Update scene settings", description="Update frame range and frame rate (30 fps)", default=True, ) create_new_action: bpy.props.BoolProperty( name="Create New Action", description="Create a new action when importing VMD, otherwise add keyframes to existing actions if available. Note: This option is ignored when 'Use NLA' is enabled.", default=False, ) use_nla: bpy.props.BoolProperty( name="Use NLA", description="Import the motion as NLA strips", default=False, ) detect_camera_changes: bpy.props.BoolProperty( name="Detect Camera Cut", description="When the interval between camera keyframes is 1 frame, change the interpolation to CONSTANT. This is useful when making a 60fps video, as it helps prevent unwanted smoothing between rapid camera cuts.", default=True, ) detect_lamp_changes: bpy.props.BoolProperty( # TODO: Update all instances of "lamp" to "light" throughout the repository to align with Blender 2.80+ API changes. # This includes: # - Variable names and references # - Class/type checks (LAMP -> LIGHT) # - Documentation and comments # - Function parameters and return values # This change is necessary since Blender 2.80 renamed the "Lamp" type to "Light". name="Detect Light Cut", description="When the interval between light keyframes is 1 frame, change the interpolation to CONSTANT. This is useful when making a 60fps video, as it helps prevent unwanted smoothing during sudden lighting changes.", default=True, ) log_level: bpy.props.EnumProperty( name="Log level", description="Select log level", items=LOG_LEVEL_ITEMS, default="INFO", ) save_log: bpy.props.BoolProperty( name="Create a log file", description="Create a log file", default=False, ) @classmethod def poll(cls, context): return len(context.selected_objects) > 0 def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_vmd_import_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def draw(self, context): layout = self.layout layout.prop(self, "scale") layout.prop(self, "margin") layout.prop(self, "create_new_action") layout.prop(self, "use_nla") layout.prop(self, "bone_mapper") if self.bone_mapper == "RENAMED_BONES": layout.prop(self, "rename_bones") layout.prop(self, "use_underscore") layout.prop(self, "dictionary") layout.prop(self, "use_pose_mode") layout.prop(self, "use_mirror") layout.prop(self, "detect_camera_changes") layout.prop(self, "detect_lamp_changes") layout.prop(self, "update_scene_settings") layout.prop(self, "log_level") layout.prop(self, "save_log") def execute(self, context): logger = logger.getLogger() logger.setLevel(self.log_level) handler = None if self.save_log: handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.import.log") logger.addHandler(handler) try: selected_objects = set(context.selected_objects) for i in frozenset(selected_objects): root = FnModel.find_root_object(i) if root == i: rig = Model(root) armature = rig.armature() if armature is not None: selected_objects.add(armature) placeholder = rig.morph_slider.placeholder() if placeholder is not None: selected_objects.add(placeholder) selected_objects |= set(rig.meshes()) bone_mapper = None if self.bone_mapper == "PMX": bone_mapper = makePmxBoneMap elif self.bone_mapper == "RENAMED_BONES": bone_mapper = vmd_importer.RenamedBoneMapper( rename_LR_bones=self.rename_bones, use_underscore=self.use_underscore, translator=DictionaryEnum.get_translator(self.dictionary), ).init if self.files: if self.create_new_action: for obj in selected_objects: self.__reset_all_animations(obj) for file in self.files: start_time = time.time() importer = vmd_importer.VMDImporter( filepath=os.path.join(self.directory, file.name), scale=self.scale, bone_mapper=bone_mapper, use_pose_mode=self.use_pose_mode, frame_margin=self.margin, use_mirror=self.use_mirror, use_nla=self.use_nla, detect_camera_changes=self.detect_camera_changes, detect_lamp_changes=self.detect_lamp_changes, ) for i in selected_objects: importer.assign(i) logger.info(" Finished importing motion in %f seconds.", time.time() - start_time) if self.update_scene_settings: # auto_scene_setup.setupFrameRanges() # Not available in Avatar Toolkit # auto_scene_setup.setupFps() # Not available in Avatar Toolkit pass context.scene.frame_set(context.scene.frame_current) except Exception: logger.exception("Error occurred") err_msg = traceback.format_exc() self.report({"ERROR"}, err_msg) finally: if handler: logger.removeHandler(handler) return {"FINISHED"} def __reset_all_animations(self, target_obj): """Reset all animation states for the target object and related MMD model objects""" root_object = FnModel.find_root_object(target_obj) objects_to_process = set() if root_object: objects_to_process.add(root_object) objects_to_process.add(target_obj) # Add armature object armature_object = FnModel.find_armature_object(root_object) if armature_object: objects_to_process.add(armature_object) # Add all mesh objects objects_to_process.update(FnModel.iterate_mesh_objects(root_object)) # Add other group objects if they exist rigid_group = FnModel.find_rigid_group_object(root_object) if rigid_group: objects_to_process.add(rigid_group) joint_group = FnModel.find_joint_group_object(root_object) if joint_group: objects_to_process.add(joint_group) temporary_group = FnModel.find_temporary_group_object(root_object) if temporary_group: objects_to_process.add(temporary_group) else: objects_to_process.add(target_obj) # STEP 1: Clear all existing actions first for obj in objects_to_process: # Clear object's own actions if obj.animation_data: obj.animation_data.action = None # Clear Shape Keys actions if hasattr(obj, "data") and hasattr(obj.data, "shape_keys") and obj.data.shape_keys: if obj.data.shape_keys.animation_data: obj.data.shape_keys.animation_data.action = None # Clear light data actions if obj.type == "LIGHT" and obj.data.animation_data: obj.data.animation_data.action = None # STEP 2: Reset all properties to default states for obj in objects_to_process: if obj.type == "ARMATURE": # Reset armature pose for bone in obj.pose.bones: bone.location = (0.0, 0.0, 0.0) bone.rotation_quaternion = (1.0, 0.0, 0.0, 0.0) bone.rotation_euler = (0.0, 0.0, 0.0) bone.rotation_axis_angle = (0.0, 0.0, 1.0, 0.0) bone.scale = (1.0, 1.0, 1.0) # Reset IK settings to default if hasattr(bone, "mmd_ik_toggle"): bone.mmd_ik_toggle = True elif obj.type == "MESH" and getattr(obj.data, "shape_keys", None): # Reset mesh morphs for shape_key in obj.data.shape_keys.key_blocks: if shape_key.name != "Basis": # Don't reset basis shape key shape_key.value = 0.0 elif hasattr(obj, "mmd_type") and obj.mmd_type == "ROOT": # Reset root display state if hasattr(obj, "mmd_root") and hasattr(obj.mmd_root, "show_meshes"): obj.mmd_root.show_meshes = True # Default to show meshes # VPD format not supported in Avatar Toolkit - classes below are disabled class ImportVpd(Operator, ImportHelper, PreferencesMixin): _is_registered = True # Prevent auto_load from registering this bl_idname = "mmd_tools.import_vpd" bl_label = "Import VPD File (.vpd)" bl_description = "Import VPD file(s) to selected rig's Action Pose (.vpd)\nBehavior varies depending on the selected object:\n- Select the root (cross under the model): applies both armature pose and morphs\n- Select the model: applies only morphs\n- Select the armature: applies only armature pose" bl_options = {"REGISTER", "UNDO", "PRESET"} files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) filename_ext = ".vpd" filter_glob: bpy.props.StringProperty(default="*.vpd", options={"HIDDEN"}) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for importing the pose", default=0.08, ) bone_mapper: bpy.props.EnumProperty( name="Bone Mapper", description="Select bone mapper", items=[ ("BLENDER", "Blender", "Use blender bone name", 0), ("PMX", "PMX", "Use japanese name of MMD bone", 1), ("RENAMED_BONES", "Renamed bones", "Rename the bone of pose data to be blender suitable", 2), ], default="PMX", ) rename_bones: bpy.props.BoolProperty( name="Rename Bones - L / R Suffix", description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", default=True, ) use_underscore: bpy.props.BoolProperty( name="Rename Bones - Use Underscore", description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", default=False, ) dictionary: bpy.props.EnumProperty( name="Rename Bones To English", items=DictionaryEnum.get_dictionary_items, description="Translate bone names from Japanese to English using selected dictionary", ) use_pose_mode: bpy.props.BoolProperty( name="Treat Current Pose as Rest Pose", description="You can pose the model to fit the original pose of a pose data, such as T-Pose or A-Pose", default=False, options={"SKIP_SAVE"}, ) @classmethod def poll(cls, context): return len(context.selected_objects) > 0 def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_vpd_import_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def draw(self, context): layout = self.layout layout.prop(self, "scale") layout.prop(self, "bone_mapper") if self.bone_mapper == "RENAMED_BONES": layout.prop(self, "rename_bones") layout.prop(self, "use_underscore") layout.prop(self, "dictionary") layout.prop(self, "use_pose_mode") def execute(self, context): selected_objects = set(context.selected_objects) for i in frozenset(selected_objects): root = FnModel.find_root_object(i) if root == i: rig = Model(root) armature = rig.armature() if armature is not None: selected_objects.add(armature) placeholder = rig.morph_slider.placeholder() if placeholder is not None: selected_objects.add(placeholder) selected_objects |= set(rig.meshes()) bone_mapper = None if self.bone_mapper == "PMX": bone_mapper = makePmxBoneMap elif self.bone_mapper == "RENAMED_BONES": bone_mapper = vmd_importer.RenamedBoneMapper( rename_LR_bones=self.rename_bones, use_underscore=self.use_underscore, translator=DictionaryEnum.get_translator(self.dictionary), ).init for f in self.files: importer = vpd_importer.VPDImporter( filepath=os.path.join(self.directory, f.name), scale=self.scale, bone_mapper=bone_mapper, use_pose_mode=self.use_pose_mode, ) for i in selected_objects: importer.assign(i) return {"FINISHED"} class ExportPmx(Operator, ExportHelper, PreferencesMixin): _is_registered = True # Prevent auto_load from registering this (PMX export not supported) bl_idname = "mmd_tools.export_pmx" bl_label = "Export PMX File (.pmx)" bl_description = "Export selected MMD model(s) to PMX file(s) (.pmx)" bl_options = {"PRESET"} filename_ext = ".pmx" filter_glob: bpy.props.StringProperty(default="*.pmx", options={"HIDDEN"}) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for exporting the model", default=12.5, ) copy_textures_mode: bpy.props.EnumProperty( name="Copy Textures", description="Choose how to handle texture files during export", items=[ ("NONE", "Don't Copy", "Don't copy texture files", 0), ("SKIP_EXISTING", "Copy (Skip Existing)", "Copy textures but skip files that already exist", 1), ("OVERWRITE", "Copy (Overwrite)", "Copy textures and overwrite existing files", 2), ], default="SKIP_EXISTING", ) sort_materials: bpy.props.BoolProperty( name="Sort Materials", description="Sort materials for alpha blending. WARNING: Will not work if you have transparent meshes inside the model. E.g. blush meshes", default=False, ) disable_specular: bpy.props.BoolProperty( name="Disable SPH/SPA", description="Disables all the Specular Map textures. It is required for some MME Shaders.", default=False, ) visible_meshes_only: bpy.props.BoolProperty( name="Visible Meshes Only", description="Export visible meshes only", default=False, ) export_vertex_colors_as_adduv2: bpy.props.BoolProperty( name="Export Vertex Colors", description="Export vertex colors as ADD UV2 data. This allows vertex color data to be preserved in the PMX file format. When enabled, existing ADD UV2 data on the model will be skipped during export.", default=False, ) fix_bone_order: bpy.props.BoolProperty( name="Fix Bone Order", description="Automatically fix bone order before export. This ensures bones are ordered correctly for MMD compatibility.", default=True, ) overwrite_bone_morphs_from_action_pose: bpy.props.BoolProperty( name="Overwrite Bone Morphs", description="Overwrite the bone morphs from active Action Pose before exporting.", default=False, ) translate_in_presets: bpy.props.BoolProperty( name="(Experimental) Translate in Presets", description="Translate in presets before exporting.", default=False, ) normal_handling: bpy.props.EnumProperty( name="Normal Handling", description="Choose how to handle normals during export. This affects vertex count, edge count, and mesh topology by splitting vertices and edges to preserve split normals.", items=[ ("PRESERVE_ALL_NORMALS", "Preserve All Normals", "Export existing normals without any changes. This option performs NO automatic smoothing; only use it if you have already manually smoothed and perfected your normals. When using this option, please verify if the vertex count of the exported model has significantly increased or is within a reasonable range to prevent excessive geometry destruction and an overly fragmented model.", 0), ("SMOOTH_KEEP_SHARP", "Smooth (Keep Sharp)", "Shade smooth, keep sharp edges. Balances vertex count and normal preservation.", 1), ("SMOOTH_ALL_NORMALS", "Smooth All Normals", "Force smooths all normals, ignoring any sharp edges. This will result in a completely smooth-shaded model and minimum vertex count.", 2), ], default="SMOOTH_KEEP_SHARP", ) sort_vertices: bpy.props.EnumProperty( name="Sort Vertices", description="Choose the method to sort vertices", items=[ ("NONE", "None", "No sorting", 0), ("BLENDER", "Blender", "Use blender's internal vertex order", 1), ("CUSTOM", "Custom", 'Use custom vertex weight of vertex group "mmd_vertex_order"', 2), ], default="NONE", ) ik_angle_limits: bpy.props.EnumProperty( name="IK Angle Limits", description="Choose how to handle IK angle limits during export", items=[ ( "EXPORT_ALL", "Export All Limits", "Export all existing IK angle limits using current priority system: " "mmd_ik_limit_override -> Blender IK limits -> other sources. " "If mmd_ik_limit_override disables an axis but Blender IK limits exist for that axis, " "the Blender limits will still be exported. This maintains backward compatibility " "with existing workflows", 0, ), ( "IGNORE_ALL", "Ignore All Limits", "Completely ignore all IK angle limits from any source during export. " "No angle restrictions will be written to the PMX file, regardless of " "mmd_ik_limit_override, Blender IK limits, or other constraint settings. " "Useful when you want to rely entirely on MMD v9.19+ fixed axis feature instead", 1, ), ( "OVERRIDE_CONTROLLED", "Override Controlled", "Use mmd_ik_limit_override constraints as the sole authority for IK limits. " "When mmd_ik_limit_override exists: only its enabled axes export limits, " "disabled axes export no limits (ignoring Blender IK limits). " "When mmd_ik_limit_override doesn't exist: fall back to Blender IK limits. " "This makes mmd_ik_limit_override act as a true 'override' that completely " "controls whether limits are exported, enabling fine-grained per-bone control", 2, ), ], default="EXPORT_ALL", ) log_level: bpy.props.EnumProperty( name="Log level", description="Select log level", items=LOG_LEVEL_ITEMS, default="DEBUG", ) save_log: bpy.props.BoolProperty( name="Create a log file", description="Create a log file", default=False, ) @classmethod def poll(cls, context): obj = context.active_object return obj is not None and obj in context.selected_objects and FnModel.find_root_object(obj) def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_pmx_export_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def execute(self, context): try: folder = os.path.dirname(self.filepath) models = {FnModel.find_root_object(i) for i in context.selected_objects} for root in models: if root is None: continue # use original self.filepath when export only one model # otherwise, use root object's name as file name if len(models) > 1: model_name = bpy.path.clean_name(root.name) model_folder = os.path.join(folder, model_name) os.makedirs(model_folder, exist_ok=True) self.filepath = os.path.join(model_folder, model_name + ".pmx") self._do_execute(context, root) except Exception: logger.exception("Error occurred") err_msg = traceback.format_exc() self.report({"ERROR"}, err_msg) return {"FINISHED"} def _do_execute(self, context, root): logger = logger.getLogger() logger.setLevel(self.log_level) handler = None if self.save_log: handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.export.log") logger.addHandler(handler) arm = FnModel.find_armature_object(root) if arm is None: self.report({"ERROR"}, f'[Skipped] The armature object of MMD model "{root.name}" can\'t be found') return {"CANCELLED"} orig_pose_position = None if not root.mmd_root.is_built: # use 'REST' pose when the model is not built orig_pose_position = arm.data.pose_position arm.data.pose_position = "REST" arm.update_tag() context.scene.frame_set(context.scene.frame_current) try: meshes = FnModel.iterate_mesh_objects(root) if self.visible_meshes_only: meshes = (x for x in meshes if x in context.visible_objects) pmx_exporter.export( filepath=self.filepath, scale=self.scale, root=root, armature=FnModel.find_armature_object(root), meshes=meshes, rigid_bodies=FnModel.iterate_rigid_body_objects(root), joints=FnModel.iterate_joint_objects(root), copy_textures_mode=self.copy_textures_mode, fix_bone_order=self.fix_bone_order, overwrite_bone_morphs_from_action_pose=self.overwrite_bone_morphs_from_action_pose, translate_in_presets=self.translate_in_presets, sort_materials=self.sort_materials, sort_vertices=self.sort_vertices, disable_specular=self.disable_specular, export_vertex_colors_as_adduv2=self.export_vertex_colors_as_adduv2, normal_handling=self.normal_handling, ik_angle_limits=self.ik_angle_limits, ) self.report({"INFO"}, f'Exported MMD model "{root.name}" to "{self.filepath}"') except Exception: logger.exception("Error occurred") raise finally: if orig_pose_position: arm.data.pose_position = orig_pose_position if handler: logger.removeHandler(handler) return {"FINISHED"} class ExportVmd(Operator, ExportHelper, PreferencesMixin): _is_registered = True # Prevent auto_load from registering this (VMD export not supported) bl_idname = "mmd_tools.export_vmd" bl_label = "Export VMD File (.vmd)" bl_description = "Export motion data of active object to a VMD file (.vmd)\nBehavior varies depending on the active object:\n- Active object is the root (cross under the model): exports both armature and morph animations\n- Active object is the model: exports only morph animation\n- Active object is the armature: exports only armature animation" bl_options = {"PRESET"} filename_ext = ".vmd" filter_glob: bpy.props.StringProperty(default="*.vmd", options={"HIDDEN"}) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for exporting the motion", default=12.5, ) use_pose_mode: bpy.props.BoolProperty( name="Treat Current Pose as Rest Pose", description="You can pose the model to export a motion data to different pose base, such as T-Pose or A-Pose", default=False, options={"SKIP_SAVE"}, ) use_frame_range: bpy.props.BoolProperty( name="Use Frame Range", description="Export frames only in the frame range of context scene", default=False, ) preserve_curves: bpy.props.BoolProperty( name="Preserve Animation Curves", description="Add additional keyframes to accurately preserve animation curves. Blender's bezier handles are more flexible than the VMD format. Complex handle settings will be lost during export unless additional keyframes are added to approximate the original curves.", default=False, ) log_level: bpy.props.EnumProperty( name="Log level", description="Select log level", items=LOG_LEVEL_ITEMS, default="INFO", ) save_log: bpy.props.BoolProperty( name="Create a log file", description="Create a log file", default=False, ) @classmethod def poll(cls, context): obj = context.active_object if obj is None: return False if obj.mmd_type == "ROOT": return True if obj.mmd_type == "NONE" and (obj.type == "ARMATURE" or getattr(obj.data, "shape_keys", None)): return True if MMDCamera.isMMDCamera(obj) or MMDLamp.isMMDLamp(obj): return True return False def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_vmd_export_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def execute(self, context): logger = logger.getLogger() logger.setLevel(self.log_level) handler = None if self.save_log: handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.export.log") logger.addHandler(handler) try: params = { "filepath": self.filepath, "scale": self.scale, "use_pose_mode": self.use_pose_mode, "use_frame_range": self.use_frame_range, "preserve_curves": self.preserve_curves, } obj = context.active_object if obj.mmd_type == "ROOT": rig = Model(obj) params["mesh"] = rig.morph_slider.placeholder(binded=True) or rig.firstMesh() params["armature"] = rig.armature() params["model_name"] = obj.mmd_root.name or obj.name elif getattr(obj.data, "shape_keys", None): params["mesh"] = obj params["model_name"] = obj.name elif obj.type == "ARMATURE": params["armature"] = obj params["model_name"] = obj.name else: for i in context.selected_objects: if MMDCamera.isMMDCamera(i): params["camera"] = i elif MMDLamp.isMMDLamp(i): params["lamp"] = i start_time = time.time() vmd_exporter.VMDExporter().export(**params) logger.info(" Finished exporting motion in %f seconds.", time.time() - start_time) except Exception: logger.exception("Error occurred") err_msg = traceback.format_exc() self.report({"ERROR"}, err_msg) finally: if handler: logger.removeHandler(handler) return {"FINISHED"} class ExportVpd(Operator, ExportHelper, PreferencesMixin): _is_registered = True # Prevent auto_load from registering this (VPD not supported) bl_idname = "mmd_tools.export_vpd" bl_label = "Export VPD File (.vpd)" bl_description = "Export active rig's Action Pose to VPD file(s) (.vpd)\nBehavior varies depending on the active object:\n- Active object is the root (cross under the model): exports both armature pose and morphs\n- Active object is the model: exports only morphs\n- Active object is the armature: exports only armature pose" bl_options = {"PRESET"} filename_ext = ".vpd" filter_glob: bpy.props.StringProperty(default="*.vpd", options={"HIDDEN"}) scale: bpy.props.FloatProperty( name="Scale", description="Scaling factor for exporting the pose", default=12.5, ) pose_type: bpy.props.EnumProperty( name="Pose Type", description="Choose the pose type to export", items=[ ("CURRENT", "Current Pose", "Current pose of the rig", 0), ("ACTIVE", "Active Pose", "Active pose of the rig's Action Pose", 1), ("ALL", "All Poses", "All poses of the rig's Action Pose (the pose name will be the file name)", 2), ], default="CURRENT", ) use_pose_mode: bpy.props.BoolProperty( name="Treat Current Pose as Rest Pose", description="You can pose the model to export a pose data to different pose base, such as T-Pose or A-Pose", default=False, options={"SKIP_SAVE"}, ) @classmethod def poll(cls, context): obj = context.active_object if obj is None: return False if obj.mmd_type == "ROOT": return True if obj.mmd_type == "NONE" and (obj.type == "ARMATURE" or getattr(obj.data, "shape_keys", None)): return True return False def invoke(self, context, event): self.load_preferences_on_invoke(context, "default_vpd_export_preset") return super().invoke(context, event) def cancel(self, context): self.restore_preferences_on_cancel() return super().cancel(context) if hasattr(super(), "cancel") else None def draw(self, context): layout = self.layout layout.prop(self, "scale") layout.prop(self, "pose_type", expand=True) if self.pose_type != "CURRENT": layout.prop(self, "use_pose_mode") def execute(self, context): params = { "filepath": self.filepath, "scale": self.scale, "pose_type": self.pose_type, "use_pose_mode": self.use_pose_mode, } obj = context.active_object if obj.mmd_type == "ROOT": rig = Model(obj) params["mesh"] = rig.morph_slider.placeholder(binded=True) or rig.firstMesh() params["armature"] = rig.armature() params["model_name"] = obj.mmd_root.name or obj.name elif getattr(obj.data, "shape_keys", None): params["mesh"] = obj params["model_name"] = obj.name elif obj.type == "ARMATURE": params["armature"] = obj params["model_name"] = obj.name try: vpd_exporter.VPDExporter().export(**params) except Exception: logger.exception("Error occurred") err_msg = traceback.format_exc() self.report({"ERROR"}, err_msg) return {"FINISHED"}