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
This commit is contained in:
2026-01-02 13:32:03 +08:00
parent f2257449de
commit 21222044c4
10 changed files with 10147 additions and 58 deletions

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

View File

@@ -1,8 +1,9 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later # SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from .ops import VRM_OT_separate_non_skin_faces from .ops import VRM_OT_merge_uv_edges, VRM_OT_separate_non_skin_faces
from .panel import VRM_PT_body_mesh_separator from .panel import VRM_PT_body_mesh_separator
__all__ = [ __all__ = [
"VRM_OT_merge_uv_edges",
"VRM_OT_separate_non_skin_faces", "VRM_OT_separate_non_skin_faces",
"VRM_PT_body_mesh_separator", "VRM_PT_body_mesh_separator",
] ]

View File

@@ -1,28 +1,58 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later # SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from collections.abc import Set as AbstractSet from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
import bpy import bpy
from bpy.types import Context, Operator from bpy.types import Context, Operator
from .utils import ( from .utils import (
AUTO_CONVERT_MATERIALS,
PROCESSING_ORDER,
MaterialInfo, MaterialInfo,
find_body_mesh, find_body_mesh,
find_face_mesh,
generate_unique_name, generate_unique_name,
get_material_face_count, get_material_face_count,
get_target_mesh_name,
is_retained_material,
is_separable_material,
is_skin_material, is_skin_material,
isolate_material_faces,
load_edge_uv_data,
select_edges_by_uv,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from bpy.types import Object 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_OT_separate_non_skin_faces(Operator): class VRM_OT_separate_non_skin_faces(Operator):
"""将Body网格中非皮肤材质的面分离为独立对象""" """将Body网格中非皮肤材质的面分离为独立对象"""
bl_idname = "vrm.separate_non_skin_faces" bl_idname = "vrm.separate_non_skin_faces"
bl_label = "Separate Non-Skin Faces" bl_label = "Separate Non-Skin Faces"
bl_description = "将Body网格中非皮肤材质的面分离为独立对象" bl_description = "将Body网格中可分离材质CLOTH和其他的面合并分离为单个独立对象"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"} bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
@classmethod @classmethod
@@ -36,13 +66,13 @@ class VRM_OT_separate_non_skin_faces(Operator):
if body_mesh is None: if body_mesh is None:
return False return False
# 检查是否有非皮肤材质 # 检查是否有可分离材质使用is_separable_material
mesh_data = body_mesh.data mesh_data = body_mesh.data
if mesh_data is None: if mesh_data is None:
return False return False
for material in mesh_data.materials: for material in mesh_data.materials:
if material is not None and not is_skin_material(material.name): if material is not None and is_separable_material(material.name):
return True return True
return False return False
@@ -88,13 +118,13 @@ class VRM_OT_separate_non_skin_faces(Operator):
self.report({"ERROR"}, "Body网格数据无效") self.report({"ERROR"}, "Body网格数据无效")
return {"CANCELLED"} return {"CANCELLED"}
# 收集非皮肤材质信息 # 收集可分离材质信息使用is_separable_material
non_skin_materials: list[MaterialInfo] = [] separable_materials: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials): for i, material in enumerate(mesh_data.materials):
if material is not None and not is_skin_material(material.name): if material is not None and is_separable_material(material.name):
face_count = get_material_face_count(mesh_data, i) face_count = get_material_face_count(mesh_data, i)
if face_count > 0: if face_count > 0:
non_skin_materials.append( separable_materials.append(
MaterialInfo( MaterialInfo(
name=material.name, name=material.name,
index=i, index=i,
@@ -103,39 +133,40 @@ class VRM_OT_separate_non_skin_faces(Operator):
) )
) )
if not non_skin_materials: if not separable_materials:
self.report({"INFO"}, "没有找到非皮肤材质的面") self.report({"INFO"}, "没有找到可分离材质的面")
return {"FINISHED"} return {"FINISHED"}
# 获取现有对象名称集合 # 获取现有对象名称集合
existing_names = set(obj.name for obj in context.blend_data.objects) existing_names = set(obj.name for obj in context.blend_data.objects)
# 为每个非皮肤材质执行分离 # 将所有可分离材质的面合并分离为单个对象
separated_count = 0 material_indices = [mat_info.index for mat_info in separable_materials]
for mat_info in non_skin_materials: new_obj = self._separate_all_material_faces(
result = self._separate_material_faces( context, body_mesh, armature, material_indices, existing_names
context, body_mesh, armature, mat_info, existing_names
) )
if result:
separated_count += 1
existing_names.add(result.name)
if separated_count > 0: if new_obj is not None:
self.report({"INFO"}, f"成功分离了 {separated_count} 个材质的面") total_faces = sum(mat_info.face_count for mat_info in separable_materials)
material_names = [mat_info.name for mat_info in separable_materials]
self.report(
{"INFO"},
f"成功分离了 {len(separable_materials)} 个材质的 {total_faces} 个面到 {new_obj.name}"
)
return {"FINISHED"} return {"FINISHED"}
else: else:
self.report({"WARNING"}, "没有成功分离任何面") self.report({"WARNING"}, "没有成功分离任何面")
return {"CANCELLED"} return {"CANCELLED"}
def _separate_material_faces( def _separate_all_material_faces(
self, self,
context: Context, context: Context,
body_obj: "Object", body_obj: "Object",
armature: "Object", armature: "Object",
mat_info: MaterialInfo, material_indices: list[int],
existing_names: set[str], existing_names: set[str],
) -> Optional["Object"]: ) -> Optional["Object"]:
"""分离指定材质的面新对象""" """将所有指定材质的面合并分离为单个新对象"""
mesh_data = body_obj.data mesh_data = body_obj.data
if mesh_data is None: if mesh_data is None:
return None return None
@@ -163,9 +194,10 @@ class VRM_OT_separate_non_skin_faces(Operator):
# 返回对象模式以访问网格数据 # 返回对象模式以访问网格数据
bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.mode_set(mode="OBJECT")
# 选择使用指定材质的面 # 选择所有使用可分离材质的面
material_index_set = set(material_indices)
for polygon in mesh_data.polygons: for polygon in mesh_data.polygons:
if polygon.material_index == mat_info.index: if polygon.material_index in material_index_set:
polygon.select = True polygon.select = True
# 返回编辑模式执行分离 # 返回编辑模式执行分离
@@ -182,8 +214,8 @@ class VRM_OT_separate_non_skin_faces(Operator):
if new_obj is None: if new_obj is None:
return None return None
# 生成唯一名称 # 使用固定名称"Body_Separated",处理命名冲突
base_name = self._generate_object_name(mat_info.name) base_name = "Body_Separated"
unique_name = generate_unique_name(base_name, existing_names) unique_name = generate_unique_name(base_name, existing_names)
new_obj.name = unique_name new_obj.name = unique_name
@@ -203,18 +235,405 @@ class VRM_OT_separate_non_skin_faces(Operator):
return obj return obj
return None return None
def _generate_object_name(self, material_name: str) -> str:
"""根据材质名称生成对象名称"""
# 简化材质名称,移除常见前缀和后缀
simplified_name = material_name
# 移除 "(Instance)" 后缀
if simplified_name.endswith(" (Instance)"):
simplified_name = simplified_name[:-11]
# 移除常见的VRM材质前缀
prefixes_to_remove = ["N00_000_00_", "N00_001_00_", "N00_002_00_"]
for prefix in prefixes_to_remove:
if simplified_name.startswith(prefix):
simplified_name = simplified_name[len(prefix) :]
break
return f"Body_{simplified_name}" class VRM_OT_merge_uv_edges(Operator):
"""根据UV坐标融并边将三角面转四边面"""
bl_idname = "vrm.merge_uv_edges"
bl_label = "Merge UV Edges"
bl_description = "根据UV坐标选中并融并边将三角面转换为四边面"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
"""
检查是否可以执行操作
Requirements 1.1, 1.2, 1.3, 1.4:
- 检测Armature对象或其子网格对象
- 检测Body和Face网格是否存在
- 在任意Blender模式下工作
"""
armature = cls._get_armature(context)
if armature is None:
return False
# Requirements 1.1, 1.2: 检查Body和Face网格是否都存在
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对象
Requirements 1.1: 支持选中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]:
"""
执行边融并操作
Requirements 5.3, 5.4, 6.1, 6.2:
- 保存原始状态(模式、选择、活动对象)
- 按PROCESSING_ORDER遍历材质
- 调用_process_material处理每个材质
- 恢复状态并报告结果
"""
# 保存原始状态
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状态
Requirements 7.4: 错误时恢复原始状态
"""
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]:
"""
执行边融并的核心逻辑
Requirements 6.1, 6.2: 按顺序处理材质先Body后Face
"""
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
# 按PROCESSING_ORDER遍历材质 (Requirements 6.1, 6.2)
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)
# 确保返回对象模式 (Requirements 5.4)
if context.object and context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# 报告结果 (Requirements 6.4)
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:
"""
处理单个材质的边融并
Requirements 5.1, 5.2, 6.3, 7.1, 7.2:
- 确定目标网格Body或Face
- 加载JSON边数据
- 调用isolate_material_faces隔离材质
- 调用select_edges_by_uv选择边
- 执行dissolve_edges融并
- 取消隐藏Alt+H
- 处理缺失JSON文件 (7.1)
- 处理无匹配边情况 (7.2)
"""
import bmesh
mesh_name = mesh_obj.name if mesh_obj else "Unknown"
# 检查是否使用自动转换tris_convert_to_quads
if material_name in AUTO_CONVERT_MATERIALS:
return self._process_material_auto_convert(context, mesh_obj, material_name)
try:
# 加载JSON边数据 (Requirements 5.1, 7.1)
target_uvs = load_edge_uv_data(material_name)
if target_uvs is None:
# Requirements 7.1: 记录警告并跳过
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文件中没有边数据",
)
# 隔离材质面 (Requirements 5.2)
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")
# 使用BMesh选择边
mesh_data = mesh_obj.data
bm = bmesh.from_edit_mesh(mesh_data)
# 获取UV层
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层",
)
# 选择匹配UV的边 (Requirements 5.2)
edges_selected = select_edges_by_uv(bm, uv_layer, target_uvs)
# 更新BMesh
bmesh.update_edit_mesh(mesh_data)
# Requirements 7.2: 处理无匹配边情况
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)})"
),
)
# 执行边融并 (Requirements 5.1)
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)}")
# 取消隐藏 (Alt+H equivalent) (Requirements 5.2)
bpy.ops.mesh.reveal()
# 返回对象模式 (Requirements 6.3)
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:
# Requirements 7.4: 捕获异常,尝试恢复状态
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处理规律网格的材质
对于网格结构规律的材质直接使用Blender的自动转换功能
不需要基于UV坐标的边选择。
"""
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")
# 使用Blender自带的三角面转四边面
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)}",
)

View File

@@ -7,8 +7,9 @@ from bpy.types import Context, Panel
from .utils import ( from .utils import (
MaterialInfo, MaterialInfo,
find_body_mesh, find_body_mesh,
find_face_mesh,
get_material_face_count, get_material_face_count,
is_skin_material, is_retained_material,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -69,45 +70,64 @@ class VRM_PT_body_mesh_separator(Panel):
layout.label(text="Body网格数据无效", icon="ERROR") layout.label(text="Body网格数据无效", icon="ERROR")
return return
# 显示材质统计信息 # 显示材质统计信息(区分保留材质和可分离材质)
box = layout.box() box = layout.box()
box.label(text="材质列表", icon="MATERIAL") box.label(text="材质列表", icon="MATERIAL")
# 收集材质信息 # 收集材质信息使用is_retained_material和is_separable_material分类
materials_info: list[MaterialInfo] = [] materials_info: list[MaterialInfo] = []
for i, material in enumerate(mesh_data.materials): for i, material in enumerate(mesh_data.materials):
if material is not None: if material is not None:
face_count = get_material_face_count(mesh_data, i) face_count = get_material_face_count(mesh_data, i)
is_skin = is_skin_material(material.name) is_retained = is_retained_material(material.name)
materials_info.append( materials_info.append(
MaterialInfo( MaterialInfo(
name=material.name, name=material.name,
index=i, index=i,
face_count=face_count, face_count=face_count,
is_skin=is_skin, is_skin=is_retained, # 使用is_skin字段存储保留状态
) )
) )
# 显示材质列表 # 显示材质列表区分保留材质SKIN/HAIR和可分离材质CLOTH和其他
has_non_skin = False has_separable = False
for mat_info in materials_info: for mat_info in materials_info:
row = box.row() row = box.row()
if mat_info.is_skin: if mat_info.is_skin: # is_skin字段现在表示是否为保留材质
row.label(text=f"[皮肤] {mat_info.name}", icon="CHECKMARK") # 保留材质SKIN/HAIR标记为"[保留]"
row.label(text=f"[保留] {mat_info.name}", icon="CHECKMARK")
else: else:
row.label(text=f"[非皮肤] {mat_info.name}", icon="MESH_DATA") # 可分离材质CLOTH和其他标记为"[分离]"
has_non_skin = True row.label(text=f"[分离] {mat_info.name}", icon="MESH_DATA")
has_separable = True
row.label(text=f"{mat_info.face_count}") row.label(text=f"{mat_info.face_count}")
# 显示分离操作按钮 # 显示分离操作按钮
layout.separator() layout.separator()
col = layout.column() col = layout.column()
col.enabled = has_non_skin col.enabled = has_separable
col.operator( col.operator(
"vrm.separate_non_skin_faces", "vrm.separate_non_skin_faces",
text="分离非皮肤面", text="分离非皮肤面",
icon="MESH_DATA", icon="MESH_DATA",
) )
if not has_non_skin: if not has_separable:
layout.label(text="没有非皮肤材质可分离", icon="INFO") layout.label(text="没有可分离材质", icon="INFO")
# 显示UV边融并按钮 (Requirements 1.2, 1.3)
layout.separator()
armature = self._get_armature(context)
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.merge_uv_edges",
text="根据UV转四边面",
icon="MOD_EDGESPLIT",
)
if not has_vrm_structure:
layout.label(text="需要Body和Face网格", icon="INFO")

View File

@@ -3,11 +3,19 @@
Property-based tests for body_mesh utility functions. Property-based tests for body_mesh utility functions.
Feature: vrm-body-mesh-separator Feature: vrm-body-mesh-separator
Feature: vrm-uv-edge-merger
""" """
from hypothesis import given, settings from hypothesis import given, settings
from hypothesis import strategies as st from hypothesis import strategies as st
from .utils import generate_unique_name, is_skin_material from .utils import (
find_body_mesh,
find_face_mesh,
generate_unique_name,
is_skin_material,
normalize_uv_pair,
select_material_faces,
)
class TestIsSkinMaterial: class TestIsSkinMaterial:
@@ -58,3 +66,431 @@ class TestGenerateUniqueName:
f"generate_unique_name('{base_name}', {existing_names}) " f"generate_unique_name('{base_name}', {existing_names}) "
f"returned '{result}' which is in existing_names" f"returned '{result}' which is in existing_names"
) )
class TestNormalizeUvPair:
"""
Property 4: UV坐标标准化对称性
*For any* pair of UV coordinates (uv1, uv2), normalizing (uv1, uv2) SHALL produce
the same result as normalizing (uv2, uv1), ensuring order-independent matching.
**Validates: Requirements 4.5**
"""
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
)
@settings(max_examples=100)
def test_uv_normalization_symmetry_property(
self, uv1: tuple[float, float], uv2: tuple[float, float]
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 4: UV Coordinate Normalization
For any pair of UV coordinates, normalize(uv1, uv2) == normalize(uv2, uv1).
"""
result_forward = normalize_uv_pair(uv1, uv2)
result_reverse = normalize_uv_pair(uv2, uv1)
assert result_forward == result_reverse, (
f"normalize_uv_pair({uv1}, {uv2}) = {result_forward}, "
f"but normalize_uv_pair({uv2}, {uv1}) = {result_reverse}"
)
class TestUvEdgeMatchingPrecision:
"""
Property 5: UV边匹配精度
*For any* edge with UV coordinates, the matching function SHALL correctly identify
the edge if its UV coordinates (rounded to 6 decimal places) exist in the target UV set.
**Validates: Requirements 4.2, 4.3, 4.4**
"""
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.floats(min_value=-1e-7, max_value=1e-7, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_uv_matching_precision_property(
self,
uv1: tuple[float, float],
uv2: tuple[float, float],
noise: float,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision
For any UV coordinate pair, adding noise smaller than 6 decimal precision
should still result in a match after normalization.
"""
# Create the normalized reference UV pair
normalized_ref = normalize_uv_pair(uv1, uv2)
# Add small noise (within 6 decimal precision tolerance)
uv1_noisy = (uv1[0] + noise, uv1[1] + noise)
uv2_noisy = (uv2[0] + noise, uv2[1] + noise)
# Normalize the noisy pair
normalized_noisy = normalize_uv_pair(uv1_noisy, uv2_noisy)
# Create target UV set with the reference
target_uvs = {normalized_ref}
# The noisy normalized pair should match the reference
# because the noise is within 6 decimal precision
matches = normalized_noisy in target_uvs
# Calculate expected match based on rounding behavior
# If noise is small enough, rounding should produce same result
expected_match = normalized_ref == normalized_noisy
assert matches == expected_match, (
f"UV matching inconsistency: "
f"normalized_ref={normalized_ref}, normalized_noisy={normalized_noisy}, "
f"noise={noise}, matches={matches}, expected={expected_match}"
)
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
)
@settings(max_examples=100)
def test_uv_matching_set_membership_property(
self,
uv1: tuple[float, float],
uv2: tuple[float, float],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision
For any UV coordinate pair, the normalized pair should be found in a target
set containing that same normalized pair, regardless of original order.
"""
# Normalize in both orders
normalized_forward = normalize_uv_pair(uv1, uv2)
normalized_reverse = normalize_uv_pair(uv2, uv1)
# Create target set with forward normalization
target_uvs = {normalized_forward}
# Both should match (symmetry property ensures this)
assert normalized_forward in target_uvs, (
f"Forward normalized pair {normalized_forward} not found in target set"
)
assert normalized_reverse in target_uvs, (
f"Reverse normalized pair {normalized_reverse} not found in target set "
f"(forward was {normalized_forward})"
)
class MockPolygon:
"""Mock polygon for testing material face selection."""
def __init__(self, material_index: int) -> None:
self.material_index = material_index
class MockMesh:
"""Mock mesh data for testing material face selection."""
def __init__(self, polygons: list[MockPolygon]) -> None:
self.polygons = polygons
class MockMeshObject:
"""Mock mesh object for testing material face selection."""
def __init__(self, mesh_data: MockMesh) -> None:
self.type = "MESH"
self.data = mesh_data
class TestMaterialFaceSelectionCompleteness:
"""
Property 6: Material Face Selection Completeness
*For any* mesh and material index, the face selection function SHALL select
exactly all faces that have the specified material index, and no others.
**Validates: Requirements 3.2**
"""
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=1,
max_size=50,
),
st.integers(min_value=0, max_value=5),
)
@settings(max_examples=100)
def test_material_face_selection_completeness_property(
self,
material_indices: list[int],
target_material_index: int,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh with faces having various material indices, select_material_faces
returns exactly the set of face indices that have the specified material index.
"""
# Create mock polygons with the given material indices
polygons = [MockPolygon(idx) for idx in material_indices]
mock_mesh = MockMesh(polygons)
mock_obj = MockMeshObject(mock_mesh)
# Call the function under test
result = select_material_faces(mock_obj, target_material_index)
# Calculate expected result: indices of faces with the target material
expected = {
i for i, mat_idx in enumerate(material_indices)
if mat_idx == target_material_index
}
# Verify completeness: result should contain exactly the expected faces
assert result == expected, (
f"select_material_faces returned {result}, expected {expected}. "
f"Material indices: {material_indices}, target: {target_material_index}"
)
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=0,
max_size=50,
),
)
@settings(max_examples=100)
def test_material_face_selection_no_false_positives_property(
self,
material_indices: list[int],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh, selecting faces for a material index that doesn't exist
should return an empty set.
"""
# Create mock polygons
polygons = [MockPolygon(idx) for idx in material_indices]
mock_mesh = MockMesh(polygons)
mock_obj = MockMeshObject(mock_mesh)
# Use a material index that definitely doesn't exist
non_existent_index = max(material_indices, default=-1) + 10
# Call the function under test
result = select_material_faces(mock_obj, non_existent_index)
# Should return empty set for non-existent material
assert result == set(), (
f"select_material_faces returned {result} for non-existent material "
f"index {non_existent_index}, expected empty set"
)
def test_material_face_selection_invalid_object(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
Selecting faces from None or non-mesh object should return empty set.
"""
# Test with None
result_none = select_material_faces(None, 0)
assert result_none == set(), "Expected empty set for None object"
# Test with non-mesh object (mock with wrong type)
class NonMeshObject:
type = "ARMATURE"
data = None
result_non_mesh = select_material_faces(NonMeshObject(), 0)
assert result_non_mesh == set(), "Expected empty set for non-mesh object"
class MockChild:
"""Mock child object for testing VRM model detection."""
def __init__(self, name: str, obj_type: str = "MESH") -> None:
self.name = name
self.type = obj_type
class MockArmature:
"""Mock armature object for testing VRM model detection."""
def __init__(self, children: list[MockChild]) -> None:
self.type = "ARMATURE"
self.children = children
class TestVRMModelDetectionCorrectness:
"""
Property 1: VRM Model Detection Correctness
*For any* Blender object hierarchy, the VRM detection function SHALL return True
if and only if the hierarchy contains an Armature with both "Body" and "Face"
mesh children, regardless of the current Blender mode.
**Validates: Requirements 1.1, 1.2, 1.3, 1.4**
"""
@given(
st.lists(
st.tuples(
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
st.sampled_from(["MESH", "ARMATURE", "EMPTY", "LIGHT", "CAMERA"]),
),
min_size=0,
max_size=10,
),
)
@settings(max_examples=100)
def test_vrm_detection_requires_both_body_and_face(
self,
children_specs: list[tuple[str, str]],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
For any set of child objects, VRM detection returns True if and only if
both "Body" and "Face" mesh children exist.
"""
# Create mock children
children = [MockChild(name, obj_type) for name, obj_type in children_specs]
mock_armature = MockArmature(children)
# Check if Body and Face meshes exist
has_body_mesh = any(
c.name == "Body" and c.type == "MESH" for c in children
)
has_face_mesh = any(
c.name == "Face" and c.type == "MESH" for c in children
)
# Test find_body_mesh
body_result = find_body_mesh(mock_armature)
if has_body_mesh:
assert body_result is not None, (
f"find_body_mesh should find Body mesh in {children_specs}"
)
assert body_result.name == "Body", (
f"find_body_mesh returned wrong object: {body_result.name}"
)
else:
assert body_result is None, (
f"find_body_mesh should return None for {children_specs}"
)
# Test find_face_mesh
face_result = find_face_mesh(mock_armature)
if has_face_mesh:
assert face_result is not None, (
f"find_face_mesh should find Face mesh in {children_specs}"
)
assert face_result.name == "Face", (
f"find_face_mesh returned wrong object: {face_result.name}"
)
else:
assert face_result is None, (
f"find_face_mesh should return None for {children_specs}"
)
# VRM detection should be True only if both exist
expected_vrm_valid = has_body_mesh and has_face_mesh
actual_vrm_valid = body_result is not None and face_result is not None
assert actual_vrm_valid == expected_vrm_valid, (
f"VRM detection mismatch: expected {expected_vrm_valid}, "
f"got {actual_vrm_valid} for {children_specs}"
)
def test_vrm_detection_with_valid_vrm_structure(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
A valid VRM structure with both Body and Face meshes should be detected.
"""
children = [
MockChild("Body", "MESH"),
MockChild("Face", "MESH"),
MockChild("Hair", "MESH"),
]
mock_armature = MockArmature(children)
body = find_body_mesh(mock_armature)
face = find_face_mesh(mock_armature)
assert body is not None, "Body mesh should be found"
assert face is not None, "Face mesh should be found"
assert body.name == "Body"
assert face.name == "Face"
def test_vrm_detection_with_missing_body(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Missing Body mesh should result in invalid VRM detection.
"""
children = [
MockChild("Face", "MESH"),
MockChild("Hair", "MESH"),
]
mock_armature = MockArmature(children)
body = find_body_mesh(mock_armature)
face = find_face_mesh(mock_armature)
assert body is None, "Body mesh should not be found"
assert face is not None, "Face mesh should be found"
def test_vrm_detection_with_missing_face(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Missing Face mesh should result in invalid VRM detection.
"""
children = [
MockChild("Body", "MESH"),
MockChild("Hair", "MESH"),
]
mock_armature = MockArmature(children)
body = find_body_mesh(mock_armature)
face = find_face_mesh(mock_armature)
assert body is not None, "Body mesh should be found"
assert face is None, "Face mesh should not be found"
def test_vrm_detection_with_non_mesh_body(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Body object that is not a MESH should not be detected as Body mesh.
"""
children = [
MockChild("Body", "EMPTY"), # Not a mesh
MockChild("Face", "MESH"),
]
mock_armature = MockArmature(children)
body = find_body_mesh(mock_armature)
face = find_face_mesh(mock_armature)
assert body is None, "Non-mesh Body should not be found"
assert face is not None, "Face mesh should be found"
def test_vrm_detection_with_none_armature(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
None armature should return None for both meshes.
"""
body = find_body_mesh(None)
face = find_face_mesh(None)
assert body is None, "Body should be None for None armature"
assert face is None, "Face should be None for None armature"

View File

@@ -1,9 +1,52 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later # SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import json
import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from bpy.types import Mesh, Object 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 @dataclass
@@ -35,6 +78,64 @@ def find_body_mesh(armature: "Object") -> Optional["Object"]:
return None 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: def is_skin_material(material_name: str) -> bool:
""" """
判断材质是否为皮肤材质 判断材质是否为皮肤材质
@@ -48,6 +149,39 @@ def is_skin_material(material_name: str) -> bool:
return "SKIN" in material_name.upper() 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: def get_material_face_count(mesh: "Mesh", material_index: int) -> int:
""" """
获取指定材质的面数量 获取指定材质的面数量
@@ -86,3 +220,229 @@ def generate_unique_name(base_name: str, existing_names: set[str]) -> str:
if candidate not in existing_names: if candidate not in existing_names:
return candidate return candidate
suffix += 1 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

View File

@@ -434,6 +434,7 @@ classes: list[
ops.VRM_OT_show_blend_file_addon_compatibility_warning, ops.VRM_OT_show_blend_file_addon_compatibility_warning,
ops.VRM_OT_make_estimated_humanoid_t_pose, ops.VRM_OT_make_estimated_humanoid_t_pose,
body_mesh_ops.VRM_OT_separate_non_skin_faces, body_mesh_ops.VRM_OT_separate_non_skin_faces,
body_mesh_ops.VRM_OT_merge_uv_edges,
validation.VrmValidationError, validation.VrmValidationError,
validation.WM_OT_vrm_validator, validation.WM_OT_vrm_validator,
export_scene.WM_OT_vrm_export_human_bones_assignment, export_scene.WM_OT_vrm_export_human_bones_assignment,

728
tests/test_uv_matching.py Normal file
View File

@@ -0,0 +1,728 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
"""
Standalone property-based tests for UV edge matching functionality.
Feature: vrm-uv-edge-merger
Property 5: UV Edge Matching Precision
These tests can run without Blender dependencies.
"""
from dataclasses import dataclass
from typing import Optional
from hypothesis import given, settings
from hypothesis import strategies as st
# UV坐标精度小数位数- copied from utils.py
UV_PRECISION = 6
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)
class TestUvEdgeMatchingPrecision:
"""
Property 5: UV边匹配精度
*For any* edge with UV coordinates, the matching function SHALL correctly identify
the edge if its UV coordinates (rounded to 6 decimal places) exist in the target UV set.
**Validates: Requirements 4.2, 4.3, 4.4**
"""
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.floats(min_value=-1e-7, max_value=1e-7, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_uv_matching_precision_property(
self,
uv1: tuple[float, float],
uv2: tuple[float, float],
noise: float,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision
For any UV coordinate pair, adding noise smaller than 6 decimal precision
should still result in a match after normalization.
"""
# Create the normalized reference UV pair
normalized_ref = normalize_uv_pair(uv1, uv2)
# Add small noise (within 6 decimal precision tolerance)
uv1_noisy = (uv1[0] + noise, uv1[1] + noise)
uv2_noisy = (uv2[0] + noise, uv2[1] + noise)
# Normalize the noisy pair
normalized_noisy = normalize_uv_pair(uv1_noisy, uv2_noisy)
# Create target UV set with the reference
target_uvs = {normalized_ref}
# The noisy normalized pair should match the reference
# because the noise is within 6 decimal precision
matches = normalized_noisy in target_uvs
# Calculate expected match based on rounding behavior
# If noise is small enough, rounding should produce same result
expected_match = normalized_ref == normalized_noisy
assert matches == expected_match, (
f"UV matching inconsistency: "
f"normalized_ref={normalized_ref}, normalized_noisy={normalized_noisy}, "
f"noise={noise}, matches={matches}, expected={expected_match}"
)
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
)
@settings(max_examples=100)
def test_uv_matching_set_membership_property(
self,
uv1: tuple[float, float],
uv2: tuple[float, float],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision
For any UV coordinate pair, the normalized pair should be found in a target
set containing that same normalized pair, regardless of original order.
"""
# Normalize in both orders
normalized_forward = normalize_uv_pair(uv1, uv2)
normalized_reverse = normalize_uv_pair(uv2, uv1)
# Create target set with forward normalization
target_uvs = {normalized_forward}
# Both should match (symmetry property ensures this)
assert normalized_forward in target_uvs, (
f"Forward normalized pair {normalized_forward} not found in target set"
)
assert normalized_reverse in target_uvs, (
f"Reverse normalized pair {normalized_reverse} not found in target set "
f"(forward was {normalized_forward})"
)
@given(
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
),
)
@settings(max_examples=100)
def test_uv_precision_rounding_property(
self,
uv1: tuple[float, float],
uv2: tuple[float, float],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision
For any UV coordinate pair, the normalized result should have coordinates
rounded to exactly 6 decimal places.
"""
normalized = normalize_uv_pair(uv1, uv2)
# Check that all coordinates are rounded to 6 decimal places
for uv in normalized:
for coord in uv:
# Verify rounding by checking round(coord, 6) == coord
assert round(coord, UV_PRECISION) == coord, (
f"Coordinate {coord} is not rounded to {UV_PRECISION} decimal places"
)
def select_material_faces_impl(
polygons_material_indices: list[int],
material_index: int,
) -> set[int]:
"""
获取指定材质索引的所有面的索引集合
这是一个纯函数实现,用于测试材质面选择的完整性。
Args:
polygons_material_indices: 每个面的材质索引列表
material_index: 目标材质索引
Returns:
使用该材质的面索引集合
"""
result: set[int] = set()
for i, mat_idx in enumerate(polygons_material_indices):
if mat_idx == material_index:
result.add(i)
return result
class TestMaterialFaceSelectionCompleteness:
"""
Property 6: Material Face Selection Completeness
*For any* mesh and material index, the face selection function SHALL select
exactly all faces that have the specified material index, and no others.
**Validates: Requirements 3.2**
"""
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=1,
max_size=50,
),
st.integers(min_value=0, max_value=5),
)
@settings(max_examples=100)
def test_material_face_selection_completeness_property(
self,
material_indices: list[int],
target_material_index: int,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh with faces having various material indices, select_material_faces
returns exactly the set of face indices that have the specified material index.
"""
# Call the function under test
result = select_material_faces_impl(material_indices, target_material_index)
# Calculate expected result: indices of faces with the target material
expected = {
i for i, mat_idx in enumerate(material_indices)
if mat_idx == target_material_index
}
# Verify completeness: result should contain exactly the expected faces
assert result == expected, (
f"select_material_faces returned {result}, expected {expected}. "
f"Material indices: {material_indices}, target: {target_material_index}"
)
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=0,
max_size=50,
),
)
@settings(max_examples=100)
def test_material_face_selection_no_false_positives_property(
self,
material_indices: list[int],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh, selecting faces for a material index that doesn't exist
should return an empty set.
"""
# Use a material index that definitely doesn't exist
non_existent_index = max(material_indices, default=-1) + 10
# Call the function under test
result = select_material_faces_impl(material_indices, non_existent_index)
# Should return empty set for non-existent material
assert result == set(), (
f"select_material_faces returned {result} for non-existent material "
f"index {non_existent_index}, expected empty set"
)
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=1,
max_size=50,
),
st.integers(min_value=0, max_value=5),
)
@settings(max_examples=100)
def test_material_face_selection_no_duplicates_property(
self,
material_indices: list[int],
target_material_index: int,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh, the selected face indices should be unique (no duplicates).
"""
result = select_material_faces_impl(material_indices, target_material_index)
# Since result is a set, it inherently has no duplicates
# But we verify the count matches expected
expected_count = sum(
1 for mat_idx in material_indices if mat_idx == target_material_index
)
assert len(result) == expected_count, (
f"Expected {expected_count} faces, got {len(result)}. "
f"Material indices: {material_indices}, target: {target_material_index}"
)
@given(
st.lists(
st.integers(min_value=0, max_value=5),
min_size=1,
max_size=50,
),
st.integers(min_value=0, max_value=5),
)
@settings(max_examples=100)
def test_material_face_selection_valid_indices_property(
self,
material_indices: list[int],
target_material_index: int,
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness
For any mesh, all returned face indices should be valid (within bounds).
"""
result = select_material_faces_impl(material_indices, target_material_index)
# All indices should be valid
for face_idx in result:
assert 0 <= face_idx < len(material_indices), (
f"Invalid face index {face_idx} for mesh with "
f"{len(material_indices)} faces"
)
def find_body_mesh_impl(children: list[tuple[str, str]]) -> bool:
"""
检查子对象列表中是否存在名为"Body"的MESH对象
Args:
children: 子对象列表,每个元素为 (name, type) 元组
Returns:
如果存在名为"Body"的MESH对象返回True否则返回False
"""
for name, obj_type in children:
if name == "Body" and obj_type == "MESH":
return True
return False
def find_face_mesh_impl(children: list[tuple[str, str]]) -> bool:
"""
检查子对象列表中是否存在名为"Face"的MESH对象
Args:
children: 子对象列表,每个元素为 (name, type) 元组
Returns:
如果存在名为"Face"的MESH对象返回True否则返回False
"""
for name, obj_type in children:
if name == "Face" and obj_type == "MESH":
return True
return False
def is_valid_vrm_structure(children: list[tuple[str, str]]) -> bool:
"""
检查子对象列表是否构成有效的VRM结构
Args:
children: 子对象列表,每个元素为 (name, type) 元组
Returns:
如果同时存在Body和Face MESH对象返回True否则返回False
"""
return find_body_mesh_impl(children) and find_face_mesh_impl(children)
class TestVRMModelDetectionCorrectness:
"""
Property 1: VRM Model Detection Correctness
*For any* Blender object hierarchy, the VRM detection function SHALL return True
if and only if the hierarchy contains an Armature with both "Body" and "Face"
mesh children, regardless of the current Blender mode.
**Validates: Requirements 1.1, 1.2, 1.3, 1.4**
"""
@given(
st.lists(
st.tuples(
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
st.sampled_from(["MESH", "ARMATURE", "EMPTY", "LIGHT", "CAMERA"]),
),
min_size=0,
max_size=10,
),
)
@settings(max_examples=100)
def test_vrm_detection_requires_both_body_and_face(
self,
children_specs: list[tuple[str, str]],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
For any set of child objects, VRM detection returns True if and only if
both "Body" and "Face" mesh children exist.
"""
# Check if Body and Face meshes exist
has_body_mesh = any(
name == "Body" and obj_type == "MESH"
for name, obj_type in children_specs
)
has_face_mesh = any(
name == "Face" and obj_type == "MESH"
for name, obj_type in children_specs
)
# Test find_body_mesh_impl
body_found = find_body_mesh_impl(children_specs)
assert body_found == has_body_mesh, (
f"find_body_mesh_impl mismatch: expected {has_body_mesh}, "
f"got {body_found} for {children_specs}"
)
# Test find_face_mesh_impl
face_found = find_face_mesh_impl(children_specs)
assert face_found == has_face_mesh, (
f"find_face_mesh_impl mismatch: expected {has_face_mesh}, "
f"got {face_found} for {children_specs}"
)
# VRM detection should be True only if both exist
expected_vrm_valid = has_body_mesh and has_face_mesh
actual_vrm_valid = is_valid_vrm_structure(children_specs)
assert actual_vrm_valid == expected_vrm_valid, (
f"VRM detection mismatch: expected {expected_vrm_valid}, "
f"got {actual_vrm_valid} for {children_specs}"
)
def test_vrm_detection_with_valid_vrm_structure(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
A valid VRM structure with both Body and Face meshes should be detected.
"""
children = [
("Body", "MESH"),
("Face", "MESH"),
("Hair", "MESH"),
]
assert find_body_mesh_impl(children), "Body mesh should be found"
assert find_face_mesh_impl(children), "Face mesh should be found"
assert is_valid_vrm_structure(children), "VRM structure should be valid"
def test_vrm_detection_with_missing_body(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Missing Body mesh should result in invalid VRM detection.
"""
children = [
("Face", "MESH"),
("Hair", "MESH"),
]
assert not find_body_mesh_impl(children), "Body mesh should not be found"
assert find_face_mesh_impl(children), "Face mesh should be found"
assert not is_valid_vrm_structure(children), "VRM structure should be invalid"
def test_vrm_detection_with_missing_face(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Missing Face mesh should result in invalid VRM detection.
"""
children = [
("Body", "MESH"),
("Hair", "MESH"),
]
assert find_body_mesh_impl(children), "Body mesh should be found"
assert not find_face_mesh_impl(children), "Face mesh should not be found"
assert not is_valid_vrm_structure(children), "VRM structure should be invalid"
def test_vrm_detection_with_non_mesh_body(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Body object that is not a MESH should not be detected as Body mesh.
"""
children = [
("Body", "EMPTY"), # Not a mesh
("Face", "MESH"),
]
assert not find_body_mesh_impl(children), "Non-mesh Body should not be found"
assert find_face_mesh_impl(children), "Face mesh should be found"
assert not is_valid_vrm_structure(children), "VRM structure should be invalid"
def test_vrm_detection_with_non_mesh_face(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Face object that is not a MESH should not be detected as Face mesh.
"""
children = [
("Body", "MESH"),
("Face", "ARMATURE"), # Not a mesh
]
assert find_body_mesh_impl(children), "Body mesh should be found"
assert not find_face_mesh_impl(children), "Non-mesh Face should not be found"
assert not is_valid_vrm_structure(children), "VRM structure should be invalid"
def test_vrm_detection_with_empty_children(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness
Empty children list should result in invalid VRM detection.
"""
children: list[tuple[str, str]] = []
assert not find_body_mesh_impl(children), "Body should not be found in empty list"
assert not find_face_mesh_impl(children), "Face should not be found in empty list"
assert not is_valid_vrm_structure(children), "VRM structure should be invalid"
@dataclass
class MockOperationState:
"""Mock operation state for testing state restoration."""
original_mode: str
original_active_object: Optional[str] # Object name
original_selected_objects: list[str] # Object names
processed_materials: list[str]
def save_state_impl(
current_mode: str,
active_object_name: Optional[str],
selected_object_names: list[str],
) -> MockOperationState:
"""
保存当前状态的纯函数实现
Args:
current_mode: 当前Blender模式
active_object_name: 当前活动对象名称
selected_object_names: 当前选中对象名称列表
Returns:
保存的状态对象
"""
return MockOperationState(
original_mode=current_mode,
original_active_object=active_object_name,
original_selected_objects=list(selected_object_names),
processed_materials=[],
)
def restore_state_impl(
state: MockOperationState,
available_objects: set[str],
) -> tuple[str, Optional[str], list[str]]:
"""
恢复状态的纯函数实现
Args:
state: 保存的状态
available_objects: 当前可用的对象名称集合
Returns:
(恢复的模式, 恢复的活动对象, 恢复的选中对象列表)
"""
# 恢复选中对象(只恢复仍然存在的对象)
restored_selected = [
name for name in state.original_selected_objects
if name in available_objects
]
# 恢复活动对象(只有在对象仍然存在时才恢复)
restored_active = None
if state.original_active_object and state.original_active_object in available_objects:
restored_active = state.original_active_object
# 恢复模式
restored_mode = state.original_mode
return (restored_mode, restored_active, restored_selected)
class TestStateRestorationOnError:
"""
Property 7: State Restoration on Error
*For any* error during processing, the system SHALL restore the original Blender
state (mode, active object, selection) before reporting the error.
**Validates: Requirements 7.4**
"""
@given(
st.sampled_from(["OBJECT", "EDIT", "SCULPT", "VERTEX_PAINT", "WEIGHT_PAINT"]),
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()) | st.none(),
st.lists(
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
min_size=0,
max_size=5,
),
)
@settings(max_examples=100)
def test_state_save_and_restore_property(
self,
original_mode: str,
active_object: Optional[str],
selected_objects: list[str],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error
For any initial state, saving and then restoring should return to the
original state (assuming all objects still exist).
"""
# Save state
state = save_state_impl(original_mode, active_object, selected_objects)
# Verify state was saved correctly
assert state.original_mode == original_mode
assert state.original_active_object == active_object
assert state.original_selected_objects == selected_objects
# Create available objects set (all objects exist)
available_objects = set(selected_objects)
if active_object:
available_objects.add(active_object)
# Restore state
restored_mode, restored_active, restored_selected = restore_state_impl(
state, available_objects
)
# Verify restoration
assert restored_mode == original_mode, (
f"Mode not restored: expected {original_mode}, got {restored_mode}"
)
assert restored_active == active_object, (
f"Active object not restored: expected {active_object}, got {restored_active}"
)
assert set(restored_selected) == set(selected_objects), (
f"Selected objects not restored: expected {selected_objects}, "
f"got {restored_selected}"
)
@given(
st.sampled_from(["OBJECT", "EDIT", "SCULPT"]),
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
st.lists(
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
min_size=1,
max_size=5,
),
st.sets(
st.text(min_size=1, max_size=20).filter(lambda x: x.strip()),
min_size=0,
max_size=3,
),
)
@settings(max_examples=100)
def test_state_restore_with_deleted_objects_property(
self,
original_mode: str,
active_object: str,
selected_objects: list[str],
deleted_objects: set[str],
) -> None:
"""
Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error
When some objects have been deleted, restoration should only restore
objects that still exist.
"""
# Save state
state = save_state_impl(original_mode, active_object, selected_objects)
# Create available objects (some may have been deleted)
all_objects = set(selected_objects)
all_objects.add(active_object)
available_objects = all_objects - deleted_objects
# Restore state
restored_mode, restored_active, restored_selected = restore_state_impl(
state, available_objects
)
# Mode should always be restored
assert restored_mode == original_mode
# Active object should only be restored if it still exists
if active_object in deleted_objects:
assert restored_active is None, (
f"Deleted active object should not be restored: {active_object}"
)
else:
assert restored_active == active_object
# Selected objects should only include those that still exist
for obj_name in restored_selected:
assert obj_name in available_objects, (
f"Restored selected object {obj_name} should be in available objects"
)
assert obj_name not in deleted_objects, (
f"Deleted object {obj_name} should not be in restored selection"
)
def test_state_restore_with_empty_state(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error
Restoring an empty state should work without errors.
"""
state = save_state_impl("OBJECT", None, [])
available_objects: set[str] = set()
restored_mode, restored_active, restored_selected = restore_state_impl(
state, available_objects
)
assert restored_mode == "OBJECT"
assert restored_active is None
assert restored_selected == []
def test_state_processed_materials_tracking(self) -> None:
"""
Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error
The state should track processed materials for error reporting.
"""
state = save_state_impl("OBJECT", "Armature", ["Body", "Face"])
# Simulate processing materials
state.processed_materials.append("Material1")
state.processed_materials.append("Material2")
assert len(state.processed_materials) == 2
assert "Material1" in state.processed_materials
assert "Material2" in state.processed_materials