Files
blender-vrm/editor/body_mesh/utils.py
小煜 21222044c4 feat(body_mesh): Add edge UV data for facial and hair mesh components
- Add edge UV coordinate data for N00_000_00_FaceEyelash_00_FACE mesh instance with 112 edges
- Add edge UV coordinate data for N00_000_00_HairBack_00_HAIR mesh instance with 800 edges
- Update body_mesh module initialization and operations to support new mesh data
- Enhance panel UI and utility functions for mesh edge UV handling
- Add comprehensive UV matching test suite for validation
- Update registration module to include new mesh components
- Improve test utilities for edge UV data verification
2026-01-02 13:32:03 +08:00

449 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
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
"""
# 获取当前模块所在目录
current_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(current_dir, 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:
是否成功隔离
Requirements: 3.1, 3.2, 3.3, 3.4
"""
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
# 进入编辑模式 (Requirements 3.1)
bpy.ops.object.mode_set(mode="EDIT")
# 切换到面选择模式
bpy.ops.mesh.select_mode(type="FACE")
# 取消所有选择
bpy.ops.mesh.select_all(action="DESELECT")
# 设置活动材质索引并选择该材质的所有面 (Requirements 3.2)
mesh_obj.active_material_index = material_index
bpy.ops.object.material_slot_select()
# 执行隐藏未选中 (Shift+H equivalent) (Requirements 3.3)
bpy.ops.mesh.hide(unselected=True)
# 切换到边选择模式 (Requirements 3.4)
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