diff --git a/README.md b/README.md index 644f8698..015aa218 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,4 @@ -# MMD Tools -MMD Tools is a Blender add-on for importing MMD (MikuMikuDance) model data (.pmd, .pmx), motion data (.vmd), and pose data (.vpd). -Exporting model data (.pmx), motion data (.vmd), and pose data (.vpd) are supported as well. - -MMD ToolsはMMD(MikuMikuDance)のモデルデータ(.pmd, .pmx)、モーションデータ(.vmd)、ポーズデータ(.vpd)を -インポートするためのBlenderアドオンです。 -モデルデータ(.pmx)、モーションデータ(.vmd)、ポーズデータ(.vpd)のエクスポートにも対応しています。 - -## Version Compatibility -| Blender Version | MMD Tools Version | Branch | -|-----------------|-------------------|-------------| -| Blender 4.2 LTS | MMD Tools v4.x | [main](https://github.com/MMD-Blender/blender_mmd_tools) | -| Blender 3.6 LTS | MMD Tools v2.x | [blender-v3](https://github.com/MMD-Blender/blender_mmd_tools/tree/blender-v3) | - -Use the MMD Tools version that matches your Blender LTS version. - -## Installation & Usage -- Check [the MMD Tools Wiki](https://mmd-blender.fandom.com/wiki/MMD_Tools) for details. -- 詳細は [MMD ToolsのWiki (日本語)](https://mmd-blender.fandom.com/ja/wiki/MMD_Tools) を確認してください。 - -## Contributing -MMD Tools needs contributions such as: - -- Document writing / translation -- Video creation / translation -- Bug reports -- Feature requests -- Pull requests - -If you are interested in supporting this project, please reach out via the following channels: -- [MMD Tools Issues](https://github.com/UuuNyaa/blender_mmd_tools/issues) -- [MMD & Blender Discord Server](https://discord.gg/zRgUkuaPWw) - -For developers looking to contribute code or translations, please check the [Developer Guide](DEVELOPER_GUIDE.md) for project guidelines and detailed workflows. - -## License -Distributed under the [GPLv3](LICENSE). +Some Extension on MMD Tools +- Drag in MMD file to import +- Export curve directly +- Create same mesh for rigid body diff --git a/mmd_tools/__init__.py b/mmd_tools/__init__.py index 600ec191..dc26ab19 100644 --- a/mmd_tools/__init__.py +++ b/mmd_tools/__init__.py @@ -27,15 +27,49 @@ from . import auto_load +from . import auto_export + auto_load.init(PACKAGE_NAME) +# Store keymap items to remove them when unregistering +addon_keymaps = [] + +import bpy + +class MMD_PMX_FileHandler(bpy.types.FileHandler): + bl_idname = "mmd_tools.pmx_file_handler" + bl_label = "MMD PMX Model" + bl_import_operator = "mmd_tools.import_model" # Change to your actual import operator id + bl_file_extensions = ".pmx" + bl_file_filter = "*.pmx" + bl_description = "Import MMD PMX Model by dropping into Blender" + + @classmethod + def poll_drop(cls, context): + # Allow drop in 3D View and File Browser + print(f" poll_drop") + return context.area and context.area.type in {"VIEW_3D", "FILE_BROWSER"} + + def import_drop(self, context, filepath=None, files=None, **kwargs): + print("[MMD_PMX_FileHandler] import_drop called") + print(f" filepath: {filepath}") + print(f" files: {files}") + # Call the import operator for each dropped file + if files: + for f in files: + print(f" Importing file: {f['name']}") + bpy.ops.mmd_tools.import_model(filepath=f["name"]) + elif filepath: + print(f" Importing file: {filepath}") + bpy.ops.mmd_tools.import_model(filepath=filepath) + return {'FINISHED'} def register(): import bpy from . import handlers - + bpy.utils.register_class(MMD_PMX_FileHandler) auto_load.register() # pylint: disable=import-outside-toplevel @@ -44,12 +78,35 @@ def register(): bpy.app.translations.register(PACKAGE_NAME, translations_dict) handlers.MMDHanders.register() + + auto_export.register() + + + # Register keymap + wm = bpy.context.window_manager + kc = wm.keyconfigs.addon + if kc: + km = kc.keymaps.new(name='Object Mode', space_type='EMPTY') + kmi = km.keymap_items.new("mmd_tools.export_pmx_quick", 'E', 'PRESS', ctrl=True, alt=False) + addon_keymaps.append((km, kmi)) + km1 = kc.keymaps.new(name='Curve', space_type='EMPTY') + kmi1 = km1.keymap_items.new("mmd_tools.export_pmx_quick", 'E', 'PRESS', ctrl=True, alt=False) + addon_keymaps.append((km1, kmi1)) + def unregister(): import bpy from . import handlers + + auto_export.unregister() + + + # Unregister keymap + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() handlers.MMDHanders.unregister() @@ -57,6 +114,8 @@ def unregister(): auto_load.unregister() + bpy.utils.unregister_class(MMD_PMX_FileHandler) + if __name__ == "__main__": register() diff --git a/mmd_tools/auto_export.py b/mmd_tools/auto_export.py new file mode 100644 index 00000000..3123bbc8 --- /dev/null +++ b/mmd_tools/auto_export.py @@ -0,0 +1,514 @@ +import bpy +from bpy.app.handlers import persistent +import time +from mathutils import Matrix, Vector +from . operators.fileio import ExportPmxQuick +from . core.model import FnModel +import json +import os + +# Time threshold between auto-exports (in seconds) +MIN_EXPORT_INTERVAL = 0.5 +last_export_time = 0 + +# Track object states +object_states = {} +initial_states = {} # Track initial states to detect total change + +# Track when changes were detected and when to export +last_change_time = 0 +STABILITY_THRESHOLD = 0.1 # Reduced for faster response + +# Enable/disable auto-export functionality +auto_export_enabled = False + +# Store the last known active root +last_active_root = None + +# Timer for checking stability +stability_timer = None + +# Flag to indicate changes were detected +changes_detected = False + +# Root to export after stability is achieved +root_to_export = None + +# Add this helper function at the top of the file after imports +def is_valid_object(obj): + """Check if an object still exists and is valid""" + try: + # This will raise an exception if the object has been deleted + return obj.name in bpy.context.scene.objects + except (ReferenceError, AttributeError): + return False + +# Function to export model +def export_model(root): + global last_export_time, auto_export_enabled, last_change_time, changes_detected, initial_states + + if not root: + print("No root to export") + return + + print(f"Exporting model: {root.name}") + + # Temporarily disable handler to prevent recursion during export + auto_export_enabled = False + + # Save current selection state and active object + prev_selected_objects = [obj for obj in bpy.context.selected_objects] + prev_active_object = bpy.context.active_object + prev_mode = 'OBJECT' + if prev_active_object and prev_active_object.mode: + prev_mode = prev_active_object.mode + + try: + # Properly select the root object + for obj in bpy.context.selected_objects: + obj.select_set(False) + root.select_set(True) + bpy.context.view_layer.objects.active = root + + # Make sure we're in object mode + if bpy.context.active_object and bpy.context.active_object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + # Run the export + bpy.ops.mmd_tools.export_pmx_quick() + print(f"Export complete for {root.name}") + + # Update export time and reset change time + last_export_time = time.time() + last_change_time = 0 # Reset to detect new changes + changes_detected = False + + # Reset initial states to current states + initial_states = {} # Reset initial states after export + except Exception as e: + print(f"Error during PMX export: {str(e)}") + finally: + # Re-enable handler after export completes or fails + auto_export_enabled = True + + # Restore previous selection state + for obj in bpy.context.selected_objects: + obj.select_set(False) + + for obj in prev_selected_objects: + if obj and obj.name in bpy.context.view_layer.objects: + obj.select_set(True) + + # Restore active object + if prev_active_object and prev_active_object.name in bpy.context.view_layer.objects: + bpy.context.view_layer.objects.active = prev_active_object + + # Restore previous mode if possible + if prev_mode != 'OBJECT' and prev_active_object.mode == 'OBJECT': + try: + bpy.ops.object.mode_set(mode=prev_mode) + except Exception as e: + print(f"Could not restore previous mode: {str(e)}") + +# Add this function to export mesh data for C++ consumption +def export_mesh_data_for_cpp(root): + """Export mesh data to a JSON file that can be easily read by C++ applications""" + print(f"Exporting mesh data for C++ for {root.name}") + + try: + # Find the export path used for the PMX + export_path = bpy.context.scene.get("mmd_tools_export_pmx_last_filepath", "") + + if not export_path: + # Use a default path if no previous export + export_path = bpy.path.abspath("//") or os.path.join(os.path.expanduser("~"), "Documents") + export_path = os.path.join(export_path, f"{root.name}_mesh_data.json") + else: + # Use the same directory but with .json extension + export_path = os.path.splitext(export_path)[0] + "_mesh_data.json" + + # Get all mesh objects + meshes = list(FnModel.iterate_mesh_objects(root)) + + # Prepare data structure + mesh_data = { + "model_name": root.name, + "meshes": [] + } + + # For each mesh, extract data + for mesh_obj in meshes: + if not mesh_obj or not hasattr(mesh_obj, 'data'): + continue + + # Ensure we're working with evaluated mesh data + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_mesh_obj = mesh_obj.evaluated_get(depsgraph) + mesh = eval_mesh_obj.data + + # Get transform + world_matrix = mesh_obj.matrix_world + + # Extract basic mesh data + mesh_info = { + "name": mesh_obj.name, + "vertices": [], + "faces": [], + "normals": [], + "uvs": [], + } + + # Get vertex data + for vertex in mesh.vertices: + # Transform vertex to world space + world_pos = world_matrix @ vertex.co + mesh_info["vertices"].append([world_pos.x, world_pos.y, world_pos.z]) + + # Transform normal to world space (ignoring translation) + world_normal = (world_matrix.to_3x3() @ vertex.normal).normalized() + mesh_info["normals"].append([world_normal.x, world_normal.y, world_normal.z]) + + # Get face data + for poly in mesh.polygons: + mesh_info["faces"].append(list(poly.vertices)) + + # Get UV data if available + if mesh.uv_layers.active: + uv_layer = mesh.uv_layers.active.data + per_vertex_uvs = {} + + # First collect UV per loop + for poly in mesh.polygons: + for loop_idx in range(poly.loop_start, poly.loop_start + poly.loop_total): + vertex_idx = mesh.loops[loop_idx].vertex_index + uv = uv_layer[loop_idx].uv + if vertex_idx not in per_vertex_uvs: + per_vertex_uvs[vertex_idx] = [] + per_vertex_uvs[vertex_idx].append([uv.x, uv.y]) + + # Then average the UVs for each vertex + for v_idx, uvs in per_vertex_uvs.items(): + avg_u = sum(uv[0] for uv in uvs) / len(uvs) + avg_v = sum(uv[1] for uv in uvs) / len(uvs) + mesh_info["uvs"].append([avg_u, avg_v]) + + # Add this mesh to the collection + mesh_data["meshes"].append(mesh_info) + + # Write to file + with open(export_path, 'w') as f: + json.dump(mesh_data, f, indent=2) + + print(f"Mesh data for C++ written to {export_path}") + return export_path + + except Exception as e: + print(f"Error exporting mesh data for C++: {str(e)}") + return None + +# Stability check timer function +def check_stability_timeout(): + global changes_detected, root_to_export, stability_timer + + print("Stability check running") + + try: + # Check if root_to_export still exists + if changes_detected and root_to_export and is_valid_object(root_to_export): + print(f"Stability timeout reached, exporting {root_to_export.name}") + export_model(root_to_export) + else: + if root_to_export and not is_valid_object(root_to_export): + print("Root object was deleted, canceling export") + root_to_export = None + changes_detected = False + else: + print("Stability timeout reached but no root to export") + except Exception as e: + print(f"Error during stability check: {str(e)}") + finally: + # Always clean up the timer reference + stability_timer = None + + return None # Do not repeat + +# Forced check handler that runs less frequently but catches any missed movements +def forced_check_handler(): + global object_states, initial_states, last_export_time, changes_detected, root_to_export, last_active_root + + # Clean up references to deleted objects + if root_to_export and not is_valid_object(root_to_export): + root_to_export = None + + if last_active_root and not is_valid_object(last_active_root): + last_active_root = None + + current_time = time.time() + + # Skip if too soon after last export + if current_time - last_export_time < MIN_EXPORT_INTERVAL: + return 5.0 # Check again in 5 seconds + + # Skip if we're already tracking changes + if changes_detected: + return 5.0 + + try: + # Find all MMD model roots + mmd_roots = [obj for obj in bpy.context.scene.objects if hasattr(obj, 'mmd_type') and obj.mmd_type == "ROOT"] + + # Get active root + active_root = None + if bpy.context.active_object: + try: + active_root = FnModel.find_root_object(bpy.context.active_object) + except: + pass + + relevant_root = active_root if active_root else last_active_root + + if relevant_root: + significant_change = False + + # Check for significant changes that might have been missed + try: + meshes = list(FnModel.iterate_mesh_objects(relevant_root)) + + for mesh_obj in meshes: + if not mesh_obj or not hasattr(mesh_obj, 'matrix_world'): + continue + + # Get current state + loc = mesh_obj.matrix_world.to_translation() + rot = mesh_obj.matrix_world.to_euler() + scale = mesh_obj.matrix_world.to_scale() + + # Create state string + current_state = f"{loc.x:.2f},{loc.y:.2f},{loc.z:.2f}|{rot.x:.2f},{rot.y:.2f},{rot.z:.2f}|{scale.x:.2f},{scale.y:.2f},{scale.z:.2f}" + + # Check if we have an initial state + if mesh_obj.name in initial_states: + # Compare with initial state to detect cumulative changes + if initial_states[mesh_obj.name] != current_state: + print(f"Detected significant change in {mesh_obj.name} during forced check") + significant_change = True + break + else: + # Store initial state + initial_states[mesh_obj.name] = current_state + except: + pass + + # If significant changes detected, trigger export + if significant_change: + print(f"Significant change detected in forced check for {relevant_root.name}") + changes_detected = True + root_to_export = relevant_root + + # Set timer to export after stability + if stability_timer and stability_timer in bpy.app.timers.registered: + bpy.app.timers.unregister(stability_timer) + + stability_timer = bpy.app.timers.register( + check_stability_timeout, + first_interval=STABILITY_THRESHOLD + ) + except Exception as e: + print(f"Error in forced check: {str(e)}") + + return 5.0 # Run again in 5 seconds + +# Then modify the track_mmd_changes function to handle deleted objects +@persistent +def track_mmd_changes(scene): + global last_export_time, object_states, initial_states, last_change_time, auto_export_enabled + global last_active_root, stability_timer, changes_detected, root_to_export + + # Clean up references to deleted objects + if root_to_export and not is_valid_object(root_to_export): + root_to_export = None + + if last_active_root and not is_valid_object(last_active_root): + last_active_root = None + + # Clean up object_states and initial_states + deleted_keys = [] + for obj_name in object_states.keys(): + if obj_name not in scene.objects: + deleted_keys.append(obj_name) + + for key in deleted_keys: + if key in object_states: + del object_states[key] + if key in initial_states: + del initial_states[key] + + # Update auto_export_enabled based on active root's setting + active_obj_root = None + if bpy.context.active_object: + try: + active_obj_root = FnModel.find_root_object(bpy.context.active_object) + if active_obj_root and hasattr(active_obj_root, 'mmd_root'): + auto_export_enabled = active_obj_root.mmd_root.auto_export_enabled + except: + pass + + # Skip if auto-export is disabled + if not auto_export_enabled: + return + + try: + current_time = time.time() + + # Don't process too frequently if no changes + if current_time - last_export_time < MIN_EXPORT_INTERVAL and not changes_detected: + return + + # Find all MMD model roots in the scene + mmd_roots = [] + try: + mmd_roots = [obj for obj in scene.objects if hasattr(obj, 'mmd_type') and obj.mmd_type == "ROOT"] + except: + return # Skip if we can't get roots + + if not mmd_roots: + return # No MMD models to process + + # Try to find the active root from current active object + active_obj_root = None + if bpy.context.active_object: + try: + active_obj_root = FnModel.find_root_object(bpy.context.active_object) + except: + pass # Continue even if we can't find active root + + # Update last active root if we found one + if active_obj_root: + last_active_root = active_obj_root + + # Track if any object is still moving + any_moving = False + + for root in mmd_roots: + # Check if model has been exported before - REMOVED THIS RESTRICTION + # if not scene.get("mmd_tools_export_pmx_last_filepath"): + # continue + + changed = False + + try: + meshes = list(FnModel.iterate_mesh_objects(root)) + except: + continue # Skip this root if there's an error + + # Check each mesh for changes in transformation + for mesh_obj in meshes: + # Create a hash of the object's transformation + if mesh_obj and hasattr(mesh_obj, 'matrix_world'): + try: + # Get transformation components with reduced precision to avoid noise + loc = mesh_obj.matrix_world.to_translation() + rot = mesh_obj.matrix_world.to_euler() + scale = mesh_obj.matrix_world.to_scale() + + # Create state string with lower precision to catch meaningful changes + current_state = f"{loc.x:.3f},{loc.y:.3f},{loc.z:.3f}|{rot.x:.3f},{rot.y:.3f},{rot.z:.3f}|{scale.x:.3f},{scale.y:.3f},{scale.z:.3f}" + + # Store initial state if not already stored + if mesh_obj.name not in initial_states: + initial_states[mesh_obj.name] = current_state + + # Check if state changed from the previous frame + if mesh_obj.name in object_states: + if object_states[mesh_obj.name] != current_state: + any_moving = True + changed = True + # Debug + # print(f"Movement detected in {mesh_obj.name}: {object_states[mesh_obj.name]} -> {current_state}") + + # Update state + object_states[mesh_obj.name] = current_state + except Exception as e: + print(f"Error tracking object {mesh_obj.name}: {str(e)}") + continue + + # If active object is in edit mode and is part of this model, consider it changed + if bpy.context.active_object and bpy.context.active_object.mode == 'EDIT': + active_obj = bpy.context.active_object + if active_obj in meshes: + changed = True + any_moving = True + + # If this is the first detection of changes, record the time and root + if changed: + last_change_time = current_time + + if not changes_detected: + changes_detected = True + # Remember which root had changes + if root == active_obj_root or not root_to_export: + root_to_export = root + print(f"Movement detected for {root.name}") + + # Cancel any existing timer since movement is still happening + if stability_timer and stability_timer in bpy.app.timers.registered: + # print("Canceling previous stability timer - movement continuing") + bpy.app.timers.unregister(stability_timer) + stability_timer = None + + # If nothing is moving and we've detected changes, start a timer + if changes_detected and not any_moving and not stability_timer: + # The active root or the last known active root or the root that had changes + relevant_root = root_to_export + if not relevant_root: + relevant_root = active_obj_root if active_obj_root else last_active_root + + if relevant_root: + print(f"Movement stopped for {relevant_root.name}, starting stability timer") + root_to_export = relevant_root + + # Set a timer to check for stability after the threshold + try: + stability_timer = bpy.app.timers.register( + check_stability_timeout, + first_interval=STABILITY_THRESHOLD + ) + print("Stability timer registered successfully") + except Exception as e: + print(f"Failed to register stability timer: {str(e)}") + else: + print("Movement stopped but no relevant root found to export") + + except Exception as e: + print(f"Error in track_mmd_changes: {str(e)}") + +def register(): + global last_export_time, last_change_time, changes_detected, root_to_export + last_export_time = time.time() # Initialize last export time + last_change_time = 0 # Initialize to 0 to indicate no changes detected yet + changes_detected = False + root_to_export = None + + if track_mmd_changes not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(track_mmd_changes) + print("Auto-export handler registered") + + # Register the forced check handler + if not bpy.app.timers.is_registered(forced_check_handler): + bpy.app.timers.register(forced_check_handler, first_interval=5.0) + print("Forced check timer registered") + +def unregister(): + global stability_timer + + # Clean up timers + if stability_timer and stability_timer in bpy.app.timers.registered: + bpy.app.timers.unregister(stability_timer) + stability_timer = None + + if bpy.app.timers.is_registered(forced_check_handler): + bpy.app.timers.unregister(forced_check_handler) + + if track_mmd_changes in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(track_mmd_changes) + print("Auto-export handler unregistered") \ No newline at end of file diff --git a/mmd_tools/operators/__init__.py b/mmd_tools/operators/__init__.py index 036d4585..31614ba3 100644 --- a/mmd_tools/operators/__init__.py +++ b/mmd_tools/operators/__init__.py @@ -1,2 +1,3 @@ # Copyright 2014 MMD Tools authors # This file is part of MMD Tools. + diff --git a/mmd_tools/operators/add_mesh_for_rigidbody.py b/mmd_tools/operators/add_mesh_for_rigidbody.py new file mode 100644 index 00000000..ed6293f8 --- /dev/null +++ b/mmd_tools/operators/add_mesh_for_rigidbody.py @@ -0,0 +1,131 @@ +import bpy +from mathutils import Vector +from ..core.rigid_body import FnRigidBody +from ..core.model import FnModel + +class AddMeshForRigidbodyOperator(bpy.types.Operator): + bl_idname = "mmd_tools.add_mesh_for_rigidbody" + bl_label = "Add Mesh for Selected Rigid Body" + bl_description = "Create a mesh with the same size and transform as the selected rigid body" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj or obj.mmd_type != "RIGID_BODY": + self.report({'ERROR'}, "Select a rigid body object.") + return {'CANCELLED'} + + shape = obj.mmd_rigid.shape + size = Vector(obj.mmd_rigid.size) + mesh_obj = None + mesh = None + name = obj.name + "_mesh" + if shape == "BOX": + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([ + (-size.x, -size.y, -size.z), # 0: bottom front left + ( size.x, -size.y, -size.z), # 1: bottom front right + ( size.x, size.y, -size.z), # 2: bottom back right + (-size.x, size.y, -size.z), # 3: bottom back left + (-size.x, -size.y, size.z), # 4: top front left + ( size.x, -size.y, size.z), # 5: top front right + ( size.x, size.y, size.z), # 6: top back right + (-size.x, size.y, size.z), # 7: top back left + ], [], [ + (0,3,2,1), # bottom face (-Z) - corrected winding + (4,5,6,7), # top face (+Z) + (0,1,5,4), # front face (-Y) + (3,7,6,2), # back face (+Y) + (1,2,6,5), # right face (+X) + (0,4,7,3) # left face (-X) - corrected winding + ]) + mesh_obj = bpy.data.objects.new(name, mesh) + # FIX: Link to current collection + bpy.context.collection.objects.link(mesh_obj) + elif shape == "SPHERE": + bpy.ops.mesh.primitive_uv_sphere_add(radius=size.x, location=obj.location) + mesh_obj = context.active_object + mesh_obj.name = name + elif shape == "CAPSULE": + radius = size.x + height = max(size.z, size.y) + # Cylinder + bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=height , location=obj.location) + mesh_obj = context.active_object + mesh_obj.name = name + + # Top sphere + top_loc = obj.location.copy() + top_loc.z += (height ) / 2 + bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=top_loc) + top_sphere = context.active_object + top_sphere.name = name + "_top" + + # Bottom sphere + bottom_loc = obj.location.copy() + bottom_loc.z -= (height ) / 2 + bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=bottom_loc) + bottom_sphere = context.active_object + bottom_sphere.name = name + "_bottom" + + # Join all parts into one mesh + bpy.ops.object.select_all(action='DESELECT') + mesh_obj.select_set(True) + top_sphere.select_set(True) + bottom_sphere.select_set(True) + context.view_layer.objects.active = mesh_obj + bpy.ops.object.join() + else: + self.report({'ERROR'}, f"Unsupported shape: {shape}") + return {'CANCELLED'} + + if mesh_obj: + mesh_obj.matrix_world = obj.matrix_world.copy() + mesh_obj.display_type = 'SOLID' + mesh_obj.show_transparent = True + mesh_obj.hide_render = False + + # Bind mesh to the bone of the rigid body + bone_name = obj.mmd_rigid.bone + root = FnModel.find_root_object(obj) + if bone_name: + # Find the armature that has this bone + + if root: + armature_obj = FnModel.find_armature_object(root) + if armature_obj and bone_name in armature_obj.data.bones: + # Create vertex group with bone name + vertex_group = mesh_obj.vertex_groups.new(name=bone_name) + # Add all vertices to the vertex group with full weight + vertex_indices = [v.index for v in mesh_obj.data.vertices] + if vertex_indices: + vertex_group.add(vertex_indices, 1.0, 'REPLACE') + + # Create armature modifier if it doesn't exist + arm_mod = None + for mod in mesh_obj.modifiers: + if mod.type == 'ARMATURE' and mod.object == armature_obj: + arm_mod = mod + break + + if not arm_mod: + arm_mod = mesh_obj.modifiers.new(name="Armature", type='ARMATURE') + arm_mod.object = armature_obj + arm_mod.use_vertex_groups = True + + mesh_obj.select_set(True) + context.view_layer.objects.active = mesh_obj + + # Automatically attach the mesh to the MMD model + if root: + # Use the FnModel.attach_mesh_objects function to connect the mesh to the model + # This is the same function used by the "Attach Meshes to Model" operator + FnModel.attach_mesh_objects(root, [mesh_obj], True) + + return {'FINISHED'} + +def register(): + pass # Registration handled by auto_load + +def unregister(): + pass # Unregistration handled by auto_load diff --git a/mmd_tools/operators/export_cvt.py b/mmd_tools/operators/export_cvt.py new file mode 100644 index 00000000..c9941b63 --- /dev/null +++ b/mmd_tools/operators/export_cvt.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Copyright MMD Tools authors +# This file is part of MMD Tools. + +import bpy +import logging +from ..core.model import FnModel + +def convert_curves_to_meshes(context, root_object): + """ + Converts all curve objects in the model to temporary meshes for export. + Creates new temporary mesh objects without modifying the original curves. + + Args: + context: The current Blender context + root_object: The root object of the MMD model + + Returns: + tuple: (list of created mesh objects, list of original curve objects) + """ + temp_meshes = [] + curve_objects = [] + + # Find all curve objects that are part of the model's hierarchy + for obj in FnModel.iterate_child_objects(root_object): + if obj.type == 'CURVE' and obj.data is not None: + curve_objects.append(obj) + + if not curve_objects: + return [], [] + + logging.info(f"Found {len(curve_objects)} curve objects to convert") + armature_object = FnModel.find_armature_object(root_object) + + # Convert each curve to a temporary mesh + for curve_obj in curve_objects: + # Create evaluated version of the curve + depsgraph = context.evaluated_depsgraph_get() + curve_evaluated = curve_obj.evaluated_get(depsgraph) + + # Convert to mesh (non-destructive) + mesh = bpy.data.meshes.new_from_object( + curve_evaluated, + preserve_all_data_layers=True, + depsgraph=depsgraph + ) + + # Create a new mesh object with the converted data + duplicate_name = f"temp_mesh_{curve_obj.name}" + duplicate = bpy.data.objects.new(duplicate_name, mesh) + context.collection.objects.link(duplicate) + + # Keep track of original parent and constraints + orig_parent = curve_obj.parent + orig_parent_type = curve_obj.parent_type + orig_parent_bone = curve_obj.parent_bone + + # Handle parenting + if orig_parent: + # Use the same parent as the original curve + duplicate.parent = orig_parent + duplicate.parent_type = orig_parent_type + duplicate.parent_bone = orig_parent_bone + + # If it's parented to a bone, make sure we set it up correctly + if orig_parent_type == 'BONE' and orig_parent_bone: + duplicate.parent_bone = orig_parent_bone + + # Copy the transformation matrices + duplicate.matrix_world = curve_obj.matrix_world.copy() + + # Apply materials from the curve to the mesh + for mat_slot in curve_obj.material_slots: + if mat_slot.material: + duplicate.data.materials.append(mat_slot.material) + + # Add armature modifier if needed (for deformation) + if armature_object: + # Check if the original curve has an armature modifier + has_armature_mod = False + for mod in curve_obj.modifiers: + if mod.type == 'ARMATURE' and mod.object == armature_object: + has_armature_mod = True + # Add the same modifier to the duplicate + modifier = duplicate.modifiers.new(name="Armature", type="ARMATURE") + modifier.object = armature_object + modifier.use_vertex_groups = True + modifier.use_deform_preserve_volume = mod.use_deform_preserve_volume + + # If the curve is parented to the armature or a bone but has no armature modifier, + # we may still need one for weight deformation + if not has_armature_mod and orig_parent == armature_object: + modifier = duplicate.modifiers.new(name="Armature", type="ARMATURE") + modifier.object = armature_object + modifier.use_vertex_groups = True + + # Copy vertex groups from curve to mesh for weight support + for vgroup in curve_obj.vertex_groups: + if vgroup.name not in duplicate.vertex_groups: + duplicate.vertex_groups.new(name=vgroup.name) + + # Copy the original object's visibility state + duplicate.hide_viewport = curve_obj.hide_viewport + duplicate.hide_render = curve_obj.hide_render + + # Add to our list of temporary objects + temp_meshes.append(duplicate) + logging.info(f"Converted curve '{curve_obj.name}' to temporary mesh '{duplicate.name}'") + + return temp_meshes, curve_objects + + +def transfer_weights_from_curve_to_mesh(curve_obj, mesh_obj): + """ + Attempts to transfer vertex weights from a curve object to a mesh object. + This is a more complex operation that requires sampling along the curve. + + Note: This is a placeholder for future implementation. + + Args: + curve_obj: The source curve object + mesh_obj: The target mesh object + """ + # This would require more complex implementation to map curve points to mesh vertices + # For now, this is left as a placeholder for future implementation + pass diff --git a/mmd_tools/operators/fileio.py b/mmd_tools/operators/fileio.py index f7c8372b..b727cdb6 100644 --- a/mmd_tools/operators/fileio.py +++ b/mmd_tools/operators/fileio.py @@ -513,6 +513,11 @@ class ExportPmx(Operator, ExportHelper): description="Create a log file", default=False, ) + export_curves: bpy.props.BoolProperty( + name="Export Curves", + description="Convert curves to meshes before exporting", + default=True, + ) @classmethod def poll(cls, context): @@ -557,10 +562,34 @@ def _do_execute(self, context, root): arm.update_tag() context.scene.frame_set(context.scene.frame_current) + temp_meshes = [] try: - meshes = FnModel.iterate_mesh_objects(root) + # Save export settings for quick export + context.scene["mmd_tools_export_pmx_last_filepath"] = self.filepath + context.scene["mmd_tools_export_pmx_last_scale"] = self.scale + context.scene["mmd_tools_export_pmx_last_copy_textures"] = self.copy_textures + context.scene["mmd_tools_export_pmx_last_sort_materials"] = self.sort_materials + context.scene["mmd_tools_export_pmx_last_disable_specular"] = self.disable_specular + context.scene["mmd_tools_export_pmx_last_visible_meshes_only"] = self.visible_meshes_only + context.scene["mmd_tools_export_pmx_last_overwrite_bone_morphs"] = self.overwrite_bone_morphs_from_action_pose + context.scene["mmd_tools_export_pmx_last_translate_in_presets"] = self.translate_in_presets + context.scene["mmd_tools_export_pmx_last_sort_vertices"] = self.sort_vertices + context.scene["mmd_tools_export_pmx_last_log_level"] = self.log_level + context.scene["mmd_tools_export_pmx_last_save_log"] = self.save_log + context.scene["mmd_tools_export_pmx_last_export_curves"] = self.export_curves + + # Get meshes and optionally convert curves + meshes = list(FnModel.iterate_mesh_objects(root)) + + if self.export_curves: + from ..operators.export_cvt import convert_curves_to_meshes + temp_meshes, _ = convert_curves_to_meshes(context, root) + if temp_meshes: + meshes.extend(temp_meshes) + if self.visible_meshes_only: - meshes = (x for x in meshes if x in context.visible_objects) + meshes = [x for x in meshes if x in context.visible_objects] + pmx_exporter.export( filepath=self.filepath, scale=self.scale, @@ -582,6 +611,10 @@ def _do_execute(self, context, root): logging.error(err_msg) raise finally: + # Clean up temporary meshes + for mesh in temp_meshes: + bpy.data.objects.remove(mesh) + if orig_pose_position: arm.data.pose_position = orig_pose_position if self.save_log: @@ -670,6 +703,115 @@ def execute(self, context): return {"FINISHED"} +class ExportPmxQuick(Operator): + bl_idname = "mmd_tools.export_pmx_quick" + bl_label = "Quick Export PMX" + bl_description = "Export selected MMD model using last export settings" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj in context.selected_objects and FnModel.find_root_object(obj) + + def execute(self, context): + # Get export settings from addon preferences + last_filepath = context.scene.get("mmd_tools_export_pmx_last_filepath", "") + if not last_filepath: + self.report({"ERROR"}, "No previous PMX export. Please use the regular export once first.") + return {"CANCELLED"} + + try: + root = FnModel.find_root_object(context.active_object) + if root is None: + self.report({"ERROR"}, "No MMD model found") + return {"CANCELLED"} + + # Get last export settings from scene properties + scene = context.scene + scale = scene.get("mmd_tools_export_pmx_last_scale", 12.5) + copy_textures = scene.get("mmd_tools_export_pmx_last_copy_textures", True) + sort_materials = scene.get("mmd_tools_export_pmx_last_sort_materials", False) + disable_specular = scene.get("mmd_tools_export_pmx_last_disable_specular", False) + visible_meshes_only = scene.get("mmd_tools_export_pmx_last_visible_meshes_only", False) + overwrite_bone_morphs_from_action_pose = scene.get("mmd_tools_export_pmx_last_overwrite_bone_morphs", False) + translate_in_presets = scene.get("mmd_tools_export_pmx_last_translate_in_presets", False) + sort_vertices = scene.get("mmd_tools_export_pmx_last_sort_vertices", "NONE") + log_level = scene.get("mmd_tools_export_pmx_last_log_level", "DEBUG") + save_log = scene.get("mmd_tools_export_pmx_last_save_log", False) + export_curves = scene.get("mmd_tools_export_pmx_last_export_curves", True) + + # Setup logging + logger = logging.getLogger() + logger.setLevel(log_level) + handler = None + if save_log: + handler = log_handler(log_level, filepath=last_filepath + ".mmd_tools.export.log") + logger.addHandler(handler) + + arm = FnModel.find_armature_object(root) + if arm is None: + self.report({"ERROR"}, 'The armature object of MMD model "%s" can\'t be found' % root.name) + 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) + + temp_meshes = [] + try: + # Get meshes and optionally convert curves + meshes = list(FnModel.iterate_mesh_objects(root)) + + if export_curves: + from ..operators.export_cvt import convert_curves_to_meshes + temp_meshes, _ = convert_curves_to_meshes(context, root) + if temp_meshes: + meshes.extend(temp_meshes) + + if visible_meshes_only: + meshes = [x for x in meshes if x in context.visible_objects] + + pmx_exporter.export( + filepath=last_filepath, + scale=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=copy_textures, + overwrite_bone_morphs_from_action_pose=overwrite_bone_morphs_from_action_pose, + translate_in_presets=translate_in_presets, + sort_materials=sort_materials, + sort_vertices=sort_vertices, + disable_specular=disable_specular, + ) + self.report({"INFO"}, 'Exported MMD model "%s" to "%s"' % (root.name, last_filepath)) + except: + err_msg = traceback.format_exc() + logging.error(err_msg) + raise + finally: + # Clean up temporary meshes + for mesh in temp_meshes: + bpy.data.objects.remove(mesh) + + if orig_pose_position: + arm.data.pose_position = orig_pose_position + if save_log and handler: + logger.removeHandler(handler) + + return {"FINISHED"} + except Exception as e: + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + return {"CANCELLED"} + + class ExportVpd(Operator, ExportHelper): bl_idname = "mmd_tools.export_vpd" bl_label = "Export VPD File (.vpd)" diff --git a/mmd_tools/operators/rigid_body.py b/mmd_tools/operators/rigid_body.py index ac6eff38..135d550c 100644 --- a/mmd_tools/operators/rigid_body.py +++ b/mmd_tools/operators/rigid_body.py @@ -446,9 +446,43 @@ def execute(self, context): bones = cast(bpy.types.Armature, armature_object.data).bones bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]] = {r: bones.get(r.mmd_rigid.bone, None) for r in FnModel.iterate_rigid_body_objects(root_object) if r.select_get()} - if len(bone_map) < 2: - self.report({"ERROR"}, "Please select two or more mmd rigid objects") + # If only one rigid body is selected, create a joint at its location + if len(bone_map) == 1: + rigid = next(iter(bone_map.keys())) + joint_object = FnRigidBody.new_joint_object(context, FnModel.ensure_joint_group_object(context, root_object), FnModel.get_empty_display_size(root_object)) + + # Use the rigid body's name for the joint + name_j = rigid.mmd_rigid.name_j or rigid.name + name_e = rigid.mmd_rigid.name_e or rigid.name + + # Use the location and rotation of the rigid body for the joint + loc = rigid.location + rot = rigid.rotation_euler + + # Set up the joint with itself as both objects (user will need to set second object manually) + joint = FnRigidBody.setup_joint_object( + obj=joint_object, + name=name_j + "_joint", + name_e=name_e + "_joint", + location=loc, + rotation=rot, + rigid_a=rigid, + rigid_b=rigid, # Temporarily set same object, user needs to change + maximum_location=self.limit_linear_upper, + minimum_location=self.limit_linear_lower, + maximum_rotation=self.limit_angular_upper, + minimum_rotation=self.limit_angular_lower, + spring_linear=self.spring_linear, + spring_angular=self.spring_angular, + ) + joint.select_set(True) + self.report({"INFO"}, "Created joint at rigid body location. Please set the second rigid body manually.") + return {"FINISHED"} + elif len(bone_map) < 2: + self.report({"ERROR"}, "Please select one or more mmd rigid objects") return {"CANCELLED"} + + FnContext.select_single_object(context, root_object).select_set(False) if context.scene.rigidbody_world is None: diff --git a/mmd_tools/panels/sidebar/custom_tool.py b/mmd_tools/panels/sidebar/custom_tool.py new file mode 100644 index 00000000..8acdd74b --- /dev/null +++ b/mmd_tools/panels/sidebar/custom_tool.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright 2024 MMD Tools authors +# This file is part of MMD Tools. + +import bpy + +from . import PT_PanelBase +from ...bpyutils import FnContext +from ...core.model import FnModel + + +class MMDToolsCustomToolPanel(PT_PanelBase, bpy.types.Panel): + bl_idname = "OBJECT_PT_mmd_tools_custom_tool" + bl_label = "Custom Tool" + bl_order = 2 # This will position it after Scene Setup + + def draw(self, context: bpy.types.Context): + layout = self.layout + + # Find the active MMD model's root object + root = FnModel.find_root_object(context.active_object) + + if root is None: + layout.label(text="No MMD model selected", icon="INFO") + return + + # Get the mmd_root property + mmd_root = root.mmd_root + + + # Add auto-export checkbox + auto_export_box = layout.box() + auto_export_box.label(text="Export Settings:", icon="EXPORT") + auto_export_box.prop(mmd_root, "auto_export_enabled", text="Enable Auto-Export") + + # --- Custom: Add mesh for selected rigid body --- + layout.separator() + layout.operator("mmd_tools.add_mesh_for_rigidbody", text="Add Mesh for Selected Rigid Body", icon="MESH_CUBE") + + + + + diff --git a/mmd_tools/properties/root.py b/mmd_tools/properties/root.py index bebbb1e7..2c7db3f2 100644 --- a/mmd_tools/properties/root.py +++ b/mmd_tools/properties/root.py @@ -501,6 +501,17 @@ class MMDRoot(bpy.types.PropertyGroup): name="Translation", type=MMDTranslation, ) + + # ************************* + # Custom Tool + # ************************* + + + auto_export_enabled: bpy.props.BoolProperty( + name="Auto Export", + description="Automatically export PMX when the model is modified", + default=False, + ) @staticmethod def __get_select(prop: bpy.types.Object) -> bool: