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:
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