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

729 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
"""
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