feat: 添加VRM网格工具插件,支持网格分离和UV边融并功能

添加VRM网格工具插件,包含以下主要功能:
- Body网格分离:将非皮肤材质面分离为独立对象
- UV边融并:根据预定义UV数据将三角面转换为四边面
- 提供独立面板和操作符,与VRM官方插件兼容
- 包含材质处理顺序和自动转换逻辑
- 添加JSON数据文件和工具函数
This commit is contained in:
2026-01-02 14:11:43 +08:00
commit 3bbb46bca7
18 changed files with 96964 additions and 0 deletions

1
.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Placeholder file to ensure directory is tracked by git

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# VRM Mesh Tools
独立的 Blender 网格工具插件,提供 VRM 模型的网格分离和 UV 边融并功能。
## 功能特性
- **Body Mesh Separator模型分离**: 将 Body 网格中非皮肤材质CLOTH 等)的面分离为独立对象
- **UV Edge Merger四边面转换**: 根据 UV 坐标将三角面转换为四边面
## 系统要求
- Blender 4.2.0 或更高版本
## 安装方法
### 方法一:通过扩展管理器安装(推荐)
1. 下载插件的 zip 文件
2. 打开 Blender进入 `Edit > Preferences > Get Extensions`
3. 点击右上角的下拉菜单,选择 `Install from Disk...`
4. 选择下载的 zip 文件
5. 安装完成后,插件会自动启用
### 方法二:手动安装
1. 下载并解压插件文件
2.`vrm_mesh_tools` 文件夹复制到 Blender 的扩展目录:
- Windows: `%APPDATA%\Blender Foundation\Blender\4.2\extensions\user_default\`
- macOS: `~/Library/Application Support/Blender/4.2/extensions/user_default/`
- Linux: `~/.config/blender/4.2/extensions/user_default/`
3. 重启 Blender
4. 进入 `Edit > Preferences > Add-ons`,搜索 "VRM Mesh Tools" 并启用
## 使用方法
### 访问工具面板
1. 在 3D 视图中,按 `N` 键打开侧边栏
2. 找到 "VRM Tools" 标签页
3. 选中包含 Body 网格的 Armature 对象(或其子网格对象)
### Body Mesh Separator分离非皮肤面
此功能将 Body 网格中的非皮肤材质面(如 CLOTH分离为独立对象。
1. 选中 VRM 模型的 Armature
2. 在 "VRM Tools" 面板中查看材质列表
- `[保留]` 标记的材质SKIN、HAIR将保留在原网格中
- `[分离]` 标记的材质CLOTH 等)将被分离
3. 点击 "分离非皮肤面" 按钮
4. 分离后的面将创建为名为 "Body_Separated" 的新对象
### UV Edge Merger根据 UV 转四边面)
此功能根据预定义的 UV 坐标数据,将三角面转换为四边面。
1. 确保模型同时包含 Body 和 Face 网格
2. 选中 VRM 模型的 Armature
3. 点击 "根据UV转四边面" 按钮
4. 插件将自动处理所有支持的材质
## 与 VRM 官方插件的兼容性
本插件设计为可以与 [VRM-Addon-for-Blender](https://github.com/saturday06/VRM-Addon-for-Blender)VRM 官方插件)同时安装和使用。
### 命名空间隔离
为确保兼容性,本插件使用完全独立的命名空间:
| 组件 | VRM 官方插件 | VRM Mesh Tools |
|------|-------------|----------------|
| 插件 ID | `vrm` | `vrm_mesh_tools` |
| 操作符前缀 | `vrm.` | `vrm_tools.` |
| 面板前缀 | `VRM_PT_` | `VRM_TOOLS_PT_` |
| 侧边栏分类 | `VRM` | `VRM Tools` |
### 注意事项
- 两个插件可以同时启用,不会产生冲突
- 本插件不依赖 VRM 官方插件的任何模块
- 如果同时安装了两个插件,它们会在侧边栏显示为不同的标签页
## 支持的材质
插件内置了以下 VRoid 模型材质的 UV 边数据:
- `N00_000_00_Body_00_SKIN (Instance)` - Body 皮肤
- `N00_000_00_Face_00_SKIN (Instance)` - Face 皮肤
- `N00_000_00_FaceMouth_00_FACE (Instance)` - 嘴部
- `N00_000_00_FaceEyeline_00_FACE (Instance)` - 眼线
- `N00_000_00_FaceEyelash_00_FACE (Instance)` - 睫毛
- `N00_000_00_hH_00_FACE (Instance)` - 面部细节
- `N00_000_00_EyeIris_00_EYE (Instance)` - 虹膜
- `N00_000_00_EyeWhite_00_EYE (Instance)` - 眼白
- `N00_000_00_EyeHighlight_00_EYE (Instance)` - 眼部高光
- `N00_000_00_HairBack_00_HAIR (Instance)` - 后发
## 错误处理
- 如果未找到 Body 网格,面板将显示错误提示
- 如果未找到 Face 网格,"根据UV转四边面" 按钮将被禁用
- 如果某个材质的 JSON 数据文件缺失,该材质将被跳过并显示警告
## 许可证
MIT License
## 问题反馈
如果您遇到任何问题或有功能建议,请在项目仓库中提交 Issue。或者私信bilibili用户ID383687791

45
__init__.py Normal file
View File

@@ -0,0 +1,45 @@
# SPDX-License-Identifier: MIT
# VRM Mesh Tools - Standalone mesh separation and UV edge merging tools
bl_info = {
"name": "VRM Mesh Tools",
"author": "VRM Mesh Tools Contributors",
"version": (1, 0, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > VRM Tools",
"description": "Standalone mesh separation and UV edge merging tools for VRM models",
"warning": "",
"support": "COMMUNITY",
"category": "Mesh",
}
from .ops import (
VRM_TOOLS_OT_separate_non_skin_faces,
VRM_TOOLS_OT_merge_uv_edges,
)
from .panel import (
VRM_TOOLS_PT_mesh_separator,
)
# Classes to register
_classes: list[type] = [
VRM_TOOLS_OT_separate_non_skin_faces,
VRM_TOOLS_OT_merge_uv_edges,
VRM_TOOLS_PT_mesh_separator,
]
def register() -> None:
"""Register all classes for the addon."""
import bpy
for cls in _classes:
bpy.utils.register_class(cls)
def unregister() -> None:
"""Unregister all classes for the addon."""
import bpy
for cls in reversed(_classes):
bpy.utils.unregister_class(cls)

19
blender_manifest.toml Normal file
View File

@@ -0,0 +1,19 @@
schema_version = "1.0.0"
id = "vrm_mesh_tools"
version = "1.0.0"
name = "VRM Mesh Tools"
tagline = "Standalone mesh separation and UV edge merging tools for VRM models"
maintainer = "VRM Mesh Tools Contributors"
type = "add-on"
tags = ["Modeling", "Mesh"]
blender_version_min = "4.2.0"
license = ["SPDX:MIT"]
copyright = [
"2026 VRM Mesh Tools Contributors",
]

2
data/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Placeholder file to ensure directory is tracked by git
# This directory contains JSON edge data files for UV edge merging

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
{
"description": "Edge UV data for N00_000_00_FaceEyelash_00_FACE (Instance)",
"total_edges": 112,
"edge_uvs": [
[[0.544165, 0.631242], [0.505881, 0.532443]],
[[0.630136, 0.631242], [0.677712, 0.736516]],
[[0.725608, 0.736516], [0.771321, 0.631242]],
[[0.853103, 0.736516], [0.878623, 0.624128]],
[[0.964185, 0.530252], [0.995744, 0.41732]],
[[0.455835, 0.631242], [0.494119, 0.532443]],
[[0.415292, 0.736516], [0.455835, 0.631242]],
[[0.369864, 0.736516], [0.322288, 0.855226]],
[[0.274392, 0.855226], [0.228679, 0.736516]],
[[0.228679, 0.983897], [0.183674, 0.855226]],
[[0.108634, 0.726003], [0.098715, 0.59326]],
[[0.064526, 0.469594], [0.089458, 0.517009]],
[[0.004256, 0.243915], [0.035177, 0.384901]],
[[0.584708, 0.855226], [0.630136, 0.983897]],
[[0.544165, 0.736516], [0.584708, 0.855226]],
[[0.630136, 0.532794], [0.677712, 0.631242]],
[[0.771321, 0.631242], [0.810676, 0.532573]],
[[0.816326, 0.855226], [0.853103, 0.736516]],
[[0.878623, 0.624128], [0.886484, 0.513762]],
[[0.913097, 0.33099], [0.936432, 0.243915]],
[[0.455835, 0.855226], [0.494119, 0.736516]],
[[0.415292, 0.983897], [0.455835, 0.855226]],
[[0.322288, 0.855226], [0.274392, 0.983897]],
[[0.228679, 0.736516], [0.188503, 0.631242]],
[[0.137685, 0.855226], [0.108634, 0.726003]],
[[0.067401, 0.59014], [0.098715, 0.59326]],
[[0.087542, 0.421032], [0.107709, 0.454409]],
[[0.584708, 0.631242], [0.544165, 0.532493]],
[[0.677712, 0.736516], [0.725608, 0.855226]],
[[0.820334, 0.983897], [0.862315, 0.855226]],
[[0.920375, 0.68592], [0.962269, 0.684334]],
[[0.886484, 0.513762], [0.910542, 0.517009]],
[[0.912458, 0.421032], [0.936432, 0.355213]],
[[0.415292, 0.631242], [0.455835, 0.532493]],
[[0.274392, 0.532794], [0.228679, 0.631242]],
[[0.274392, 0.983897], [0.228679, 0.855226]],
[[0.188503, 0.631242], [0.157148, 0.532738]],
[[0.126838, 0.983897], [0.089528, 0.849711]],
[[0.004256, 0.78906], [0.051958, 0.804902]],
[[0.035815, 0.530252], [0.067401, 0.59014]],
[[0.035177, 0.243915], [0.063568, 0.355213]],
[[0.544165, 0.736516], [0.505881, 0.631242]],
[[0.584708, 0.631242], [0.630136, 0.736516]],
[[0.677712, 0.631242], [0.725608, 0.736516]],
[[0.813115, 0.736516], [0.84716, 0.631242]],
[[0.910472, 0.849711], [0.920375, 0.68592]],
[[0.962269, 0.684334], [0.995744, 0.596958]],
[[0.892291, 0.378519], [0.913097, 0.33099]],
[[0.892291, 0.3095], [0.913097, 0.243915]],
[[0.415292, 0.736516], [0.369864, 0.855226]],
[[0.369864, 0.631242], [0.322288, 0.736516]],
[[0.274392, 0.736516], [0.228679, 0.631242]],
[[0.146897, 0.736516], [0.121376, 0.624128]],
[[0.004256, 0.41732], [0.035815, 0.530252]],
[[0.505881, 0.855226], [0.544165, 0.983897]],
[[0.584708, 0.532593], [0.630136, 0.631242]],
[[0.677712, 0.53292], [0.725608, 0.631242]],
[[0.771321, 0.855226], [0.813115, 0.736516]],
[[0.84716, 0.631242], [0.869062, 0.53317]],
[[0.93276, 0.983897], [0.948042, 0.804902]],
[[0.935474, 0.469594], [0.964823, 0.384901]],
[[0.455835, 0.736516], [0.494119, 0.631242]],
[[0.415292, 0.855226], [0.455835, 0.736516]],
[[0.369864, 0.855226], [0.322288, 0.983897]],
[[0.369864, 0.532794], [0.322288, 0.631242]],
[[0.228679, 0.631242], [0.189324, 0.532573]],
[[0.183674, 0.855226], [0.146897, 0.736516]],
[[0.121376, 0.624128], [0.113516, 0.513762]],
[[0.063568, 0.243915], [0.086903, 0.33099]],
[[0.630136, 0.855226], [0.677712, 0.983897]],
[[0.584708, 0.736516], [0.630136, 0.855226]],
[[0.677712, 0.855226], [0.725608, 0.983897]],
[[0.630136, 0.736516], [0.677712, 0.855226]],
[[0.725608, 0.855226], [0.771321, 0.736516]],
[[0.771321, 0.983897], [0.816326, 0.855226]],
[[0.891366, 0.726003], [0.901285, 0.59326]],
[[0.910542, 0.517009], [0.935474, 0.469594]],
[[0.964823, 0.384901], [0.995744, 0.243915]],
[[0.322288, 0.736516], [0.274392, 0.855226]],
[[0.179666, 0.983897], [0.137685, 0.855226]],
[[0.037731, 0.684334], [0.079625, 0.68592]],
[[0.089458, 0.517009], [0.113516, 0.513762]],
[[0.063568, 0.355213], [0.087542, 0.421032]],
[[0.544165, 0.855226], [0.584708, 0.983897]],
[[0.505881, 0.736516], [0.544165, 0.855226]],
[[0.725608, 0.983897], [0.771321, 0.855226]],
[[0.771321, 0.736516], [0.811497, 0.631242]],
[[0.862315, 0.855226], [0.891366, 0.726003]],
[[0.901285, 0.59326], [0.932599, 0.59014]],
[[0.892291, 0.454409], [0.912458, 0.421032]],
[[0.455835, 0.983897], [0.494119, 0.855226]],
[[0.415292, 0.855226], [0.369864, 0.983897]],
[[0.415292, 0.631242], [0.369864, 0.736516]],
[[0.322288, 0.631242], [0.274392, 0.736516]],
[[0.186885, 0.736516], [0.15284, 0.631242]],
[[0.089528, 0.849711], [0.079625, 0.68592]],
[[0.004256, 0.596958], [0.037731, 0.684334]],
[[0.086903, 0.33099], [0.107709, 0.378519]],
[[0.086903, 0.243915], [0.107709, 0.3095]],
[[0.584708, 0.736516], [0.544165, 0.631242]],
[[0.725608, 0.532794], [0.771321, 0.631242]],
[[0.811497, 0.631242], [0.842852, 0.532738]],
[[0.873162, 0.983897], [0.910472, 0.849711]],
[[0.948042, 0.804902], [0.995744, 0.78906]],
[[0.932599, 0.59014], [0.964185, 0.530252]],
[[0.936432, 0.355213], [0.964823, 0.243915]],
[[0.415292, 0.532593], [0.369864, 0.631242]],
[[0.322288, 0.53292], [0.274392, 0.631242]],
[[0.228679, 0.855226], [0.186885, 0.736516]],
[[0.15284, 0.631242], [0.130938, 0.53317]],
[[0.06724, 0.983897], [0.051958, 0.804902]],
[[0.035177, 0.384901], [0.064526, 0.469594]]
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

553
ops.py Normal file
View File

@@ -0,0 +1,553 @@
# SPDX-License-Identifier: MIT
# VRM Mesh Tools - Operators for mesh separation and UV edge merging
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import bpy
from bpy.types import Context, Operator
from .utils import (
AUTO_CONVERT_MATERIALS,
PROCESSING_ORDER,
MaterialInfo,
find_body_mesh,
find_face_mesh,
generate_unique_name,
get_material_face_count,
get_material_index,
get_target_mesh_name,
is_separable_material,
isolate_material_faces,
load_edge_uv_data,
normalize_uv_pair,
select_edges_by_uv,
)
if TYPE_CHECKING:
from bpy.types import Object
@dataclass
class ProcessResult:
"""材质处理结果"""
material_name: str
mesh_name: str
edges_selected: int
edges_dissolved: int
success: bool
error_message: Optional[str] = None
@dataclass
class MergeOperationState:
"""操作状态,用于错误恢复"""
original_mode: str
original_active_object: Optional["Object"]
original_selected_objects: list["Object"]
processed_materials: list[str]
class VRM_TOOLS_OT_separate_non_skin_faces(Operator):
"""将Body网格中非皮肤材质的面分离为独立对象"""
bl_idname = "vrm_tools.separate_non_skin_faces"
bl_label = "Separate Non-Skin Faces"
bl_description = "将Body网格中可分离材质CLOTH和其他的面合并分离为单个独立对象"
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 is_separable_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"}
# 收集可分离材质信息
separable_materials: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials):
if material is not None and is_separable_material(material.name):
face_count = get_material_face_count(mesh_data, i)
if face_count > 0:
separable_materials.append(
MaterialInfo(
name=material.name,
index=i,
face_count=face_count,
is_skin=False,
)
)
if not separable_materials:
self.report({"INFO"}, "没有找到可分离材质的面")
return {"FINISHED"}
# 获取现有对象名称集合
existing_names = set(obj.name for obj in context.blend_data.objects)
# 将所有可分离材质的面合并分离为单个对象
material_indices = [mat_info.index for mat_info in separable_materials]
new_obj = self._separate_all_material_faces(
context, body_mesh, armature, material_indices, existing_names
)
if new_obj is not None:
total_faces = sum(mat_info.face_count for mat_info in separable_materials)
self.report(
{"INFO"},
f"成功分离了 {len(separable_materials)} 个材质的 {total_faces} 个面到 {new_obj.name}"
)
return {"FINISHED"}
else:
self.report({"WARNING"}, "没有成功分离任何面")
return {"CANCELLED"}
def _separate_all_material_faces(
self,
context: Context,
body_obj: "Object",
armature: "Object",
material_indices: list[int],
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")
# 选择所有使用可分离材质的面
material_index_set = set(material_indices)
for polygon in mesh_data.polygons:
if polygon.material_index in material_index_set:
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
# 使用固定名称"Body_Separated",处理命名冲突
base_name = "Body_Separated"
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
class VRM_TOOLS_OT_merge_uv_edges(Operator):
"""根据UV坐标融并边将三角面转四边面"""
bl_idname = "vrm_tools.merge_uv_edges"
bl_label = "Merge UV Edges"
bl_description = "根据UV坐标选中并融并边将三角面转换为四边面"
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)
face_mesh = find_face_mesh(armature)
return body_mesh is not None and face_mesh is not None
@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
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]:
"""执行边融并操作"""
state = self._save_state(context)
try:
return self._execute_merge(context, state)
except Exception as e:
self._restore_state(context, state)
self.report({"ERROR"}, f"边融并操作失败: {str(e)}")
return {"CANCELLED"}
def _save_state(self, context: Context) -> MergeOperationState:
"""保存当前Blender状态"""
original_mode = context.object.mode if context.object else "OBJECT"
original_active = context.view_layer.objects.active
original_selected = list(context.selected_objects)
return MergeOperationState(
original_mode=original_mode,
original_active_object=original_active,
original_selected_objects=original_selected,
processed_materials=[],
)
def _restore_state(self, context: Context, state: MergeOperationState) -> None:
"""恢复原始Blender状态"""
try:
if context.object and context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="DESELECT")
for obj in state.original_selected_objects:
if obj and obj.name in context.blend_data.objects:
obj.select_set(True)
if state.original_active_object and \
state.original_active_object.name in context.blend_data.objects:
context.view_layer.objects.active = state.original_active_object
if context.object and state.original_mode != "OBJECT":
try:
bpy.ops.object.mode_set(mode=state.original_mode)
except RuntimeError:
pass
except Exception:
pass
def _execute_merge(
self, context: Context, state: MergeOperationState
) -> set[str]:
"""执行边融并的核心逻辑"""
armature = self._get_armature(context)
if armature is None:
self.report({"ERROR"}, "未找到Armature对象")
return {"CANCELLED"}
body_mesh = find_body_mesh(armature)
face_mesh = find_face_mesh(armature)
if body_mesh is None or face_mesh is None:
self.report({"ERROR"}, "未找到Body或Face网格对象")
return {"CANCELLED"}
if context.object and context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
results: list[ProcessResult] = []
total_edges_dissolved = 0
materials_processed = 0
materials_skipped = 0
for material_name in PROCESSING_ORDER:
target_mesh_name = get_target_mesh_name(material_name)
if target_mesh_name is None:
continue
target_mesh = body_mesh if target_mesh_name == "Body" else face_mesh
result = self._process_material(context, target_mesh, material_name)
results.append(result)
state.processed_materials.append(material_name)
if result.success:
materials_processed += 1
total_edges_dissolved += result.edges_dissolved
else:
materials_skipped += 1
if result.error_message:
self.report({"WARNING"}, result.error_message)
if context.object and context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
if materials_processed > 0:
self.report(
{"INFO"},
f"处理完成: {materials_processed} 个材质, "
f"融并 {total_edges_dissolved} 条边"
)
return {"FINISHED"}
else:
self.report({"WARNING"}, f"没有成功处理任何材质 (跳过 {materials_skipped} 个)")
return {"CANCELLED"}
def _process_material(
self,
context: Context,
mesh_obj: "Object",
material_name: str,
) -> ProcessResult:
"""处理单个材质的边融并"""
import bmesh
mesh_name = mesh_obj.name if mesh_obj else "Unknown"
if material_name in AUTO_CONVERT_MATERIALS:
return self._process_material_auto_convert(context, mesh_obj, material_name)
try:
target_uvs = load_edge_uv_data(material_name)
if target_uvs is None:
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"跳过: 未找到材质 {material_name} 的JSON文件",
)
if len(target_uvs) == 0:
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"跳过: 材质 {material_name} 的JSON文件中没有边数据",
)
if not isolate_material_faces(context, mesh_obj, material_name):
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"跳过: 无法隔离材质 {material_name} 的面",
)
bpy.ops.mesh.select_all(action="DESELECT")
mesh_data = mesh_obj.data
bm = bmesh.from_edit_mesh(mesh_data)
uv_layer = bm.loops.layers.uv.active
if uv_layer is None:
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"跳过: 网格 {mesh_name} 没有UV层",
)
edges_selected = select_edges_by_uv(bm, uv_layer, target_uvs)
bmesh.update_edit_mesh(mesh_data)
if edges_selected == 0:
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=(
f"跳过: 材质 {material_name} 没有找到匹配的边 "
f"(目标边数: {len(target_uvs)})"
),
)
edges_dissolved = 0
try:
bpy.ops.mesh.dissolve_edges()
edges_dissolved = edges_selected
except RuntimeError as e:
self.report({"WARNING"}, f"材质 {material_name} 融并边时出现问题: {str(e)}")
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=edges_selected,
edges_dissolved=edges_dissolved,
success=True,
error_message=None,
)
except Exception as e:
try:
if context.object and context.object.mode == "EDIT":
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"处理材质 {material_name} 时发生错误: {str(e)}",
)
def _process_material_auto_convert(
self,
context: Context,
mesh_obj: "Object",
material_name: str,
) -> ProcessResult:
"""使用Blender自带的tris_convert_to_quads处理规律网格的材质"""
mesh_name = mesh_obj.name if mesh_obj else "Unknown"
try:
if not isolate_material_faces(context, mesh_obj, material_name):
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"跳过: 无法隔离材质 {material_name} 的面",
)
bpy.ops.mesh.select_mode(type="FACE")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.tris_convert_to_quads()
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=True,
error_message=None,
)
except Exception as e:
try:
if context.object and context.object.mode == "EDIT":
bpy.ops.mesh.reveal()
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
return ProcessResult(
material_name=material_name,
mesh_name=mesh_name,
edges_selected=0,
edges_dissolved=0,
success=False,
error_message=f"处理材质 {material_name} 时发生错误: {str(e)}",
)

134
panel.py Normal file
View File

@@ -0,0 +1,134 @@
# SPDX-License-Identifier: MIT
# VRM Mesh Tools - UI Panel for mesh separation and UV edge merging
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Optional
from bpy.types import Context, Panel
from .utils import (
MaterialInfo,
find_body_mesh,
find_face_mesh,
get_material_face_count,
is_retained_material,
)
if TYPE_CHECKING:
from bpy.types import Object
class VRM_TOOLS_PT_mesh_separator(Panel):
"""VRM Mesh Tools 面板 - 提供网格分离和UV边融并功能"""
bl_idname = "VRM_TOOLS_PT_mesh_separator"
bl_label = "VRM Mesh Tools"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "VRM Tools" # 独立的侧边栏分类
bl_options: AbstractSet[str] = set()
@classmethod
def poll(cls, context: Context) -> bool:
"""检查是否应该显示面板 - 仅在选中包含Body网格的Armature时显示"""
armature = cls._get_armature(context)
if armature is None:
return False
body_mesh = find_body_mesh(armature)
return body_mesh is not None
@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 draw(self, context: Context) -> None:
"""绘制面板UI"""
layout = self.layout
armature = self._get_armature(context)
if armature is None:
layout.label(text="未找到Armature对象", icon="ERROR")
return
body_mesh = find_body_mesh(armature)
if body_mesh is None:
layout.label(text="未找到Body网格对象", icon="ERROR")
return
mesh_data = body_mesh.data
if mesh_data is None:
layout.label(text="Body网格数据无效", icon="ERROR")
return
# 显示材质统计信息(区分保留材质和可分离材质)
box = layout.box()
box.label(text="材质列表", icon="MATERIAL")
# 收集材质信息使用is_retained_material分类
materials_info: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials):
if material is not None:
face_count = get_material_face_count(mesh_data, i)
is_retained = is_retained_material(material.name)
materials_info.append(
MaterialInfo(
name=material.name,
index=i,
face_count=face_count,
is_skin=is_retained, # 使用is_skin字段存储保留状态
)
)
# 显示材质列表区分保留材质SKIN/HAIR和可分离材质CLOTH和其他
has_separable = False
for mat_info in materials_info:
row = box.row()
if mat_info.is_skin: # is_skin字段现在表示是否为保留材质
# 保留材质SKIN/HAIR标记为"[保留]"
row.label(text=f"[保留] {mat_info.name}", icon="CHECKMARK")
else:
# 可分离材质CLOTH和其他标记为"[分离]"
row.label(text=f"[分离] {mat_info.name}", icon="MESH_DATA")
has_separable = True
row.label(text=f"{mat_info.face_count}")
# 显示分离操作按钮
layout.separator()
col = layout.column()
col.enabled = has_separable
col.operator(
"vrm_tools.separate_non_skin_faces",
text="分离非皮肤面",
icon="MESH_DATA",
)
if not has_separable:
layout.label(text="没有可分离材质", icon="INFO")
# 显示UV边融并按钮
layout.separator()
face_mesh = find_face_mesh(armature) if armature else None
has_vrm_structure = body_mesh is not None and face_mesh is not None
col = layout.column()
col.enabled = has_vrm_structure
col.operator(
"vrm_tools.merge_uv_edges",
text="根据UV转四边面",
icon="MOD_EDGESPLIT",
)
if not has_vrm_structure:
layout.label(text="需要Body和Face网格", icon="INFO")

448
utils.py Normal file
View File

@@ -0,0 +1,448 @@
# SPDX-License-Identifier: MIT
# VRM Mesh Tools - Utility functions for mesh separation and UV edge merging
import json
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from bmesh.types import BMesh, BMLayerItem
from bpy.types import Context, Mesh, Object
# UV坐标精度小数位数
UV_PRECISION = 6
# 材质到网格的映射
MATERIAL_MESH_MAPPING: dict[str, str] = {
"N00_000_00_Body_00_SKIN (Instance)": "Body",
"N00_000_00_Face_00_SKIN (Instance)": "Face",
"N00_000_00_FaceMouth_00_FACE (Instance)": "Face",
"N00_000_00_FaceEyeline_00_FACE (Instance)": "Face",
"N00_000_00_FaceEyelash_00_FACE (Instance)": "Face",
"N00_000_00_FaceBrow_00_FACE (Instance)": "Face",
"N00_000_00_hH_00_FACE (Instance)": "Face",
"N00_000_00_EyeIris_00_EYE (Instance)": "Face",
"N00_000_00_EyeWhite_00_EYE (Instance)": "Face",
"N00_000_00_EyeHighlight_00_EYE (Instance)": "Face",
"N00_000_00_HairBack_00_HAIR (Instance)": "Body",
}
# 使用Blender自带tris_convert_to_quads的材质网格规律不需要UV匹配
AUTO_CONVERT_MATERIALS: set[str] = {
"N00_000_00_FaceBrow_00_FACE (Instance)",
}
# 处理顺序先Body后Face
PROCESSING_ORDER: list[str] = [
"N00_000_00_Body_00_SKIN (Instance)",
"N00_000_00_HairBack_00_HAIR (Instance)",
"N00_000_00_Face_00_SKIN (Instance)",
"N00_000_00_FaceMouth_00_FACE (Instance)",
"N00_000_00_FaceEyeline_00_FACE (Instance)",
"N00_000_00_FaceEyelash_00_FACE (Instance)",
"N00_000_00_FaceBrow_00_FACE (Instance)",
"N00_000_00_hH_00_FACE (Instance)",
"N00_000_00_EyeIris_00_EYE (Instance)",
"N00_000_00_EyeWhite_00_EYE (Instance)",
"N00_000_00_EyeHighlight_00_EYE (Instance)",
]
@dataclass
class MaterialInfo:
"""材质信息数据类"""
name: str
index: int
face_count: int
is_skin: bool
def find_body_mesh(armature: "Object") -> Optional["Object"]:
"""
在Armature的子对象中查找名为"Body"的网格对象
Args:
armature: Armature对象
Returns:
Body网格对象如果不存在则返回None
"""
if armature is None:
return None
for child in armature.children:
if child.type == "MESH" and child.name == "Body":
return child
return None
def find_face_mesh(armature: "Object") -> Optional["Object"]:
"""
在Armature的子对象中查找名为"Face"的网格对象
Args:
armature: Armature对象
Returns:
Face网格对象如果不存在则返回None
"""
if armature is None:
return None
for child in armature.children:
if child.type == "MESH" and child.name == "Face":
return child
return None
def get_target_mesh_name(material_name: str) -> Optional[str]:
"""
根据材质名称获取目标网格名称
Args:
material_name: 材质名称
Returns:
目标网格名称("Body""Face"如果材质不在映射中返回None
"""
return MATERIAL_MESH_MAPPING.get(material_name)
def get_material_index(mesh_obj: "Object", material_name: str) -> Optional[int]:
"""
获取材质在网格中的索引
Args:
mesh_obj: 网格对象
material_name: 材质名称
Returns:
材质索引如果不存在返回None
"""
if mesh_obj is None or mesh_obj.type != "MESH":
return None
for i, slot in enumerate(mesh_obj.material_slots):
if slot.material and slot.material.name == material_name:
return i
return None
# 保留材质关键字常量不分离的材质只有SKIN和HAIR
RETAINED_MATERIAL_KEYWORDS = ["SKIN", "HAIR"]
def is_skin_material(material_name: str) -> bool:
"""
判断材质是否为皮肤材质
Args:
material_name: 材质名称
Returns:
如果材质名包含"SKIN"则返回True不区分大小写
"""
return "SKIN" in material_name.upper()
def is_retained_material(material_name: str) -> bool:
"""
判断材质是否为保留材质(不分离)
只有SKIN和HAIR材质保留在原Body网格中。
CLOTH和其他材质都需要分离。
Args:
material_name: 材质名称
Returns:
如果材质名包含"SKIN""HAIR"则返回True不区分大小写
"""
upper_name = material_name.upper()
return any(keyword in upper_name for keyword in RETAINED_MATERIAL_KEYWORDS)
def is_separable_material(material_name: str) -> bool:
"""
判断材质是否为可分离材质
不包含"SKIN""HAIR"的材质都是可分离材质,
包括CLOTH和其他所有材质。
Args:
material_name: 材质名称
Returns:
如果材质名不包含"SKIN""HAIR"则返回True包括CLOTH和其他材质
"""
return not is_retained_material(material_name)
def get_material_face_count(mesh: "Mesh", material_index: int) -> int:
"""
获取指定材质的面数量
Args:
mesh: 网格数据
material_index: 材质索引
Returns:
使用该材质的面数量
"""
count = 0
for polygon in mesh.polygons:
if polygon.material_index == material_index:
count += 1
return count
def generate_unique_name(base_name: str, existing_names: set[str]) -> str:
"""
生成唯一的对象名称
Args:
base_name: 基础名称
existing_names: 已存在的名称集合
Returns:
唯一的名称
"""
if base_name not in existing_names:
return base_name
suffix = 1
while True:
candidate = f"{base_name}.{suffix:03d}"
if candidate not in existing_names:
return candidate
suffix += 1
def normalize_uv_pair(
uv1: tuple[float, float], uv2: tuple[float, float]
) -> tuple[tuple[float, float], tuple[float, float]]:
"""
标准化UV坐标对较小的在前
通过将较小的UV坐标放在前面确保相同的边无论顶点顺序如何都能匹配。
使用6位小数精度进行比较和存储。
Args:
uv1: 第一个UV坐标 (u, v)
uv2: 第二个UV坐标 (u, v)
Returns:
标准化后的UV对 ((u1,v1), (u2,v2)),较小的坐标在前
"""
# 四舍五入到指定精度
uv1_rounded = (round(uv1[0], UV_PRECISION), round(uv1[1], UV_PRECISION))
uv2_rounded = (round(uv2[0], UV_PRECISION), round(uv2[1], UV_PRECISION))
# 比较并排序,较小的在前
if uv1_rounded <= uv2_rounded:
return (uv1_rounded, uv2_rounded)
return (uv2_rounded, uv1_rounded)
def load_edge_uv_data(material_name: str) -> Optional[set[tuple]]:
"""
从JSON文件加载边UV数据
Args:
material_name: 材质名称用于定位JSON文件
Returns:
UV边坐标集合格式为 {((u1,v1), (u2,v2)), ...}
如果文件不存在返回None
"""
# 获取当前模块所在目录JSON文件在data/子目录中
current_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(current_dir, "data", f"{material_name}.json")
if not os.path.exists(json_path):
return None
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
edge_uvs = data.get("edge_uvs", [])
result: set[tuple] = set()
for edge_uv in edge_uvs:
if len(edge_uv) == 2 and len(edge_uv[0]) == 2 and len(edge_uv[1]) == 2:
uv1 = (float(edge_uv[0][0]), float(edge_uv[0][1]))
uv2 = (float(edge_uv[1][0]), float(edge_uv[1][1]))
normalized = normalize_uv_pair(uv1, uv2)
result.add(normalized)
return result
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return None
def select_edges_by_uv(
bm: "BMesh",
uv_layer: "BMLayerItem",
target_uvs: set[tuple],
) -> int:
"""
根据UV坐标选中边
遍历BMesh中的所有边比较每条边的loop UV坐标与目标UV集合
选中匹配的边。使用6位小数精度进行匹配。
Args:
bm: BMesh对象
uv_layer: UV层
target_uvs: 目标UV坐标集合格式为 {((u1,v1), (u2,v2)), ...}
Returns:
选中的边数量
"""
selected_count = 0
for edge in bm.edges:
# 获取边的两个顶点关联的loop
# 每条边可能有多个关联的面我们需要检查所有关联的loop
edge_selected = False
for face in edge.link_faces:
# 找到这条边在当前面中的两个loop
edge_loops = []
for loop in face.loops:
if loop.vert in edge.verts:
edge_loops.append(loop)
if len(edge_loops) == 2:
# 获取两个loop的UV坐标
uv1_data = edge_loops[0][uv_layer]
uv2_data = edge_loops[1][uv_layer]
uv1 = (uv1_data.uv[0], uv1_data.uv[1])
uv2 = (uv2_data.uv[0], uv2_data.uv[1])
# 标准化UV对并检查是否在目标集合中
normalized = normalize_uv_pair(uv1, uv2)
if normalized in target_uvs:
edge_selected = True
break
if edge_selected:
edge.select = True
selected_count += 1
return selected_count
def isolate_material_faces(
context: "Context",
mesh_obj: "Object",
material_name: str,
) -> bool:
"""
隔离显示指定材质的面(隐藏其他面)
进入编辑模式,选择指定材质的所有面,执行隐藏未选中操作,
然后切换到边选择模式。
Args:
context: Blender上下文
mesh_obj: 网格对象
material_name: 材质名称
Returns:
是否成功隔离
"""
import bpy
if mesh_obj is None or mesh_obj.type != "MESH":
return False
# 获取材质索引
material_index = get_material_index(mesh_obj, material_name)
if material_index is None:
return False
# 保存当前活动对象和选择状态
original_active = context.view_layer.objects.active
original_mode = context.object.mode if context.object else "OBJECT"
try:
# 确保在对象模式下开始
if original_mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# 取消所有选择,选择目标网格
bpy.ops.object.select_all(action="DESELECT")
mesh_obj.select_set(True)
context.view_layer.objects.active = mesh_obj
# 进入编辑模式
bpy.ops.object.mode_set(mode="EDIT")
# 切换到面选择模式
bpy.ops.mesh.select_mode(type="FACE")
# 取消所有选择
bpy.ops.mesh.select_all(action="DESELECT")
# 设置活动材质索引并选择该材质的所有面
mesh_obj.active_material_index = material_index
bpy.ops.object.material_slot_select()
# 执行隐藏未选中 (Shift+H equivalent)
bpy.ops.mesh.hide(unselected=True)
# 切换到边选择模式
bpy.ops.mesh.select_mode(type="EDGE")
return True
except Exception:
# 发生错误时尝试恢复状态
try:
if context.object and context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
if original_active:
context.view_layer.objects.active = original_active
except Exception:
pass
return False
def select_material_faces(
mesh_obj: "Object",
material_index: int,
) -> set[int]:
"""
获取指定材质索引的所有面的索引集合
这是一个纯函数,用于测试材质面选择的完整性。
不修改任何Blender状态。
Args:
mesh_obj: 网格对象
material_index: 材质索引
Returns:
使用该材质的面索引集合
"""
if mesh_obj is None or mesh_obj.type != "MESH":
return set()
mesh = mesh_obj.data
result: set[int] = set()
for i, polygon in enumerate(mesh.polygons):
if polygon.material_index == material_index:
result.add(i)
return result