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:
118
editor/body_mesh/N00_000_00_FaceEyelash_00_FACE (Instance).json
Normal file
118
editor/body_mesh/N00_000_00_FaceEyelash_00_FACE (Instance).json
Normal 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]]
|
||||||
|
]
|
||||||
|
}
|
||||||
8006
editor/body_mesh/N00_000_00_HairBack_00_HAIR (Instance).json
Normal file
8006
editor/body_mesh/N00_000_00_HairBack_00_HAIR (Instance).json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
@@ -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)}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
728
tests/test_uv_matching.py
Normal 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
|
||||||
Reference in New Issue
Block a user