feat(body_mesh): Add body mesh separator module with edge dissolution

- Add body_mesh submodule with VRM_OT_separate_non_skin_faces operator
- Add VRM_PT_body_mesh_separator panel for UI integration
- Add utility functions for mesh operations and edge dissolution logic
- Add comprehensive edge dissolution configuration files for all body parts (head, torso, arms, legs, hands) with UV coordinates
- Add test utilities for validating mesh separation operations
- Add .gitignore to exclude Python cache files and test artifacts
- Register body_mesh module in editor package initialization
- Update registration.py to include new body mesh components
This commit is contained in:
2026-01-01 22:20:31 +08:00
parent 091ad6a49a
commit d70b36fd75
23 changed files with 87935 additions and 0 deletions

220
editor/body_mesh/ops.py Normal file
View File

@@ -0,0 +1,220 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Optional
import bpy
from bpy.types import Context, Operator
from .utils import (
MaterialInfo,
find_body_mesh,
generate_unique_name,
get_material_face_count,
is_skin_material,
)
if TYPE_CHECKING:
from bpy.types import Object
class VRM_OT_separate_non_skin_faces(Operator):
"""将Body网格中非皮肤材质的面分离为独立对象"""
bl_idname = "vrm.separate_non_skin_faces"
bl_label = "Separate Non-Skin Faces"
bl_description = "将Body网格中非皮肤材质的面分离为独立对象"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
"""检查是否可以执行分离操作"""
armature = cls._get_armature(context)
if armature is None:
return False
body_mesh = find_body_mesh(armature)
if body_mesh is None:
return False
# 检查是否有非皮肤材质
mesh_data = body_mesh.data
if mesh_data is None:
return False
for material in mesh_data.materials:
if material is not None and not is_skin_material(material.name):
return True
return False
@classmethod
def _get_armature(cls, context: Context) -> Optional["Object"]:
"""获取当前选中的Armature对象"""
active_object = context.active_object
if active_object is None:
return None
if active_object.type == "ARMATURE":
return active_object
# 如果选中的是网格检查其父对象是否为Armature
if active_object.parent is not None and active_object.parent.type == "ARMATURE":
return active_object.parent
return None
def execute(self, context: Context) -> set[str]:
"""执行分离操作"""
try:
return self._execute_separation(context)
except Exception as e:
self.report({"ERROR"}, f"分离操作失败: {str(e)}")
return {"CANCELLED"}
def _execute_separation(self, context: Context) -> set[str]:
"""执行分离操作的核心逻辑"""
armature = self._get_armature(context)
if armature is None:
self.report({"ERROR"}, "未找到Armature对象")
return {"CANCELLED"}
body_mesh = find_body_mesh(armature)
if body_mesh is None:
self.report({"ERROR"}, "未找到Body网格对象")
return {"CANCELLED"}
mesh_data = body_mesh.data
if mesh_data is None:
self.report({"ERROR"}, "Body网格数据无效")
return {"CANCELLED"}
# 收集非皮肤材质信息
non_skin_materials: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials):
if material is not None and not is_skin_material(material.name):
face_count = get_material_face_count(mesh_data, i)
if face_count > 0:
non_skin_materials.append(
MaterialInfo(
name=material.name,
index=i,
face_count=face_count,
is_skin=False,
)
)
if not non_skin_materials:
self.report({"INFO"}, "没有找到非皮肤材质的面")
return {"FINISHED"}
# 获取现有对象名称集合
existing_names = set(obj.name for obj in context.blend_data.objects)
# 为每个非皮肤材质执行分离
separated_count = 0
for mat_info in non_skin_materials:
result = self._separate_material_faces(
context, body_mesh, armature, mat_info, existing_names
)
if result:
separated_count += 1
existing_names.add(result.name)
if separated_count > 0:
self.report({"INFO"}, f"成功分离了 {separated_count} 个材质的面")
return {"FINISHED"}
else:
self.report({"WARNING"}, "没有成功分离任何面")
return {"CANCELLED"}
def _separate_material_faces(
self,
context: Context,
body_obj: "Object",
armature: "Object",
mat_info: MaterialInfo,
existing_names: set[str],
) -> Optional["Object"]:
"""分离指定材质的面为新对象"""
mesh_data = body_obj.data
if mesh_data is None:
return None
# 确保在对象模式
if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# 取消所有选择
bpy.ops.object.select_all(action="DESELECT")
# 选择Body网格并设为活动对象
body_obj.select_set(True)
context.view_layer.objects.active = body_obj
# 进入编辑模式
bpy.ops.object.mode_set(mode="EDIT")
# 取消所有选择
bpy.ops.mesh.select_all(action="DESELECT")
# 切换到面选择模式
bpy.ops.mesh.select_mode(type="FACE")
# 返回对象模式以访问网格数据
bpy.ops.object.mode_set(mode="OBJECT")
# 选择使用指定材质的面
for polygon in mesh_data.polygons:
if polygon.material_index == mat_info.index:
polygon.select = True
# 返回编辑模式执行分离
bpy.ops.object.mode_set(mode="EDIT")
# 执行分离操作
bpy.ops.mesh.separate(type="SELECTED")
# 返回对象模式
bpy.ops.object.mode_set(mode="OBJECT")
# 查找新创建的对象
new_obj = self._find_new_separated_object(context, body_obj)
if new_obj is None:
return None
# 生成唯一名称
base_name = self._generate_object_name(mat_info.name)
unique_name = generate_unique_name(base_name, existing_names)
new_obj.name = unique_name
# 设置父子关系保持与原Armature的关系
new_obj.parent = armature
new_obj.parent_type = "OBJECT"
return new_obj
def _find_new_separated_object(
self, context: Context, original_obj: "Object"
) -> Optional["Object"]:
"""查找分离操作后新创建的对象"""
# 分离后,新对象会被选中,原对象也会被选中
for obj in context.selected_objects:
if obj != original_obj and obj.type == "MESH":
return obj
return None
def _generate_object_name(self, material_name: str) -> str:
"""根据材质名称生成对象名称"""
# 简化材质名称,移除常见前缀和后缀
simplified_name = material_name
# 移除 "(Instance)" 后缀
if simplified_name.endswith(" (Instance)"):
simplified_name = simplified_name[:-11]
# 移除常见的VRM材质前缀
prefixes_to_remove = ["N00_000_00_", "N00_001_00_", "N00_002_00_"]
for prefix in prefixes_to_remove:
if simplified_name.startswith(prefix):
simplified_name = simplified_name[len(prefix) :]
break
return f"Body_{simplified_name}"