feat(vrm_mesh_tools): Add standalone VRM mesh tools addon with separation and UV merging
- Add VRM Mesh Tools addon as independent Blender extension with complete functionality - Implement Body Mesh Separator operator to isolate non-skin materials (CLOTH, etc.) from Body mesh - Implement UV Edge Merger operator to convert triangles to quads based on UV coordinates - Add comprehensive UI panel in sidebar with material list and operation buttons - Include UV edge data JSON files for 10 VRM material types (Body, Face, Eyes, Hair, etc.) - Add detailed README with installation instructions, usage guide, and compatibility notes - Configure addon manifest for Blender 4.2+ with proper namespace isolation (vrm_mesh_tools) - Ensure full compatibility with official VRM addon through independent naming conventions - Add utility functions for mesh operations, material detection, and JSON data loading
This commit is contained in:
1
vrm_mesh_tools/.gitkeep
Normal file
1
vrm_mesh_tools/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to ensure directory is tracked by git
|
||||
110
vrm_mesh_tools/README.md
Normal file
110
vrm_mesh_tools/README.md
Normal 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。
|
||||
45
vrm_mesh_tools/__init__.py
Normal file
45
vrm_mesh_tools/__init__.py
Normal 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
vrm_mesh_tools/blender_manifest.toml
Normal file
19
vrm_mesh_tools/blender_manifest.toml
Normal 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
vrm_mesh_tools/data/.gitkeep
Normal file
2
vrm_mesh_tools/data/.gitkeep
Normal 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
|
||||
48826
vrm_mesh_tools/data/N00_000_00_Body_00_SKIN (Instance).json
Normal file
48826
vrm_mesh_tools/data/N00_000_00_Body_00_SKIN (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
2646
vrm_mesh_tools/data/N00_000_00_EyeHighlight_00_EYE (Instance).json
Normal file
2646
vrm_mesh_tools/data/N00_000_00_EyeHighlight_00_EYE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
2646
vrm_mesh_tools/data/N00_000_00_EyeIris_00_EYE (Instance).json
Normal file
2646
vrm_mesh_tools/data/N00_000_00_EyeIris_00_EYE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
2586
vrm_mesh_tools/data/N00_000_00_EyeWhite_00_EYE (Instance).json
Normal file
2586
vrm_mesh_tools/data/N00_000_00_EyeWhite_00_EYE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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]]
|
||||
]
|
||||
}
|
||||
1926
vrm_mesh_tools/data/N00_000_00_FaceEyeline_00_FACE (Instance).json
Normal file
1926
vrm_mesh_tools/data/N00_000_00_FaceEyeline_00_FACE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
6606
vrm_mesh_tools/data/N00_000_00_FaceMouth_00_FACE (Instance).json
Normal file
6606
vrm_mesh_tools/data/N00_000_00_FaceMouth_00_FACE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
21166
vrm_mesh_tools/data/N00_000_00_Face_00_SKIN (Instance).json
Normal file
21166
vrm_mesh_tools/data/N00_000_00_Face_00_SKIN (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
8006
vrm_mesh_tools/data/N00_000_00_HairBack_00_HAIR (Instance).json
Normal file
8006
vrm_mesh_tools/data/N00_000_00_HairBack_00_HAIR (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
1126
vrm_mesh_tools/data/N00_000_00_hH_00_FACE (Instance).json
Normal file
1126
vrm_mesh_tools/data/N00_000_00_hH_00_FACE (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
553
vrm_mesh_tools/ops.py
Normal file
553
vrm_mesh_tools/ops.py
Normal 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
vrm_mesh_tools/panel.py
Normal file
134
vrm_mesh_tools/panel.py
Normal 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
vrm_mesh_tools/utils.py
Normal file
448
vrm_mesh_tools/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user