- 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
497 lines
18 KiB
Python
497 lines
18 KiB
Python
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
|
"""
|
|
Property-based tests for body_mesh utility functions.
|
|
|
|
Feature: vrm-body-mesh-separator
|
|
Feature: vrm-uv-edge-merger
|
|
"""
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from .utils import (
|
|
find_body_mesh,
|
|
find_face_mesh,
|
|
generate_unique_name,
|
|
is_skin_material,
|
|
normalize_uv_pair,
|
|
select_material_faces,
|
|
)
|
|
|
|
|
|
class TestIsSkinMaterial:
|
|
"""
|
|
Property 2: 材质分类正确性
|
|
*For any* 材质名称字符串,`is_skin_material`函数应该返回True当且仅当该名称包含"SKIN"子字符串(不区分大小写)。
|
|
**Validates: Requirements 2.2, 2.3**
|
|
"""
|
|
|
|
@given(st.text())
|
|
@settings(max_examples=100)
|
|
def test_skin_material_classification_property(self, material_name: str) -> None:
|
|
"""
|
|
Feature: vrm-body-mesh-separator, Property 2: 材质分类正确性
|
|
For any material name string, is_skin_material returns True
|
|
if and only if the name contains "SKIN" (case-insensitive).
|
|
"""
|
|
result = is_skin_material(material_name)
|
|
expected = "SKIN" in material_name.upper()
|
|
assert result == expected, (
|
|
f"is_skin_material('{material_name}') returned {result}, "
|
|
f"expected {expected}"
|
|
)
|
|
|
|
|
|
class TestGenerateUniqueName:
|
|
"""
|
|
Property 6: 唯一命名保证
|
|
*For any* 基础名称和已存在名称集合,`generate_unique_name`返回的名称不应该在已存在名称集合中。
|
|
**Validates: Requirements 4.2**
|
|
"""
|
|
|
|
@given(
|
|
st.text(min_size=1, max_size=50).filter(lambda x: x.strip()),
|
|
st.sets(st.text(min_size=1, max_size=50), max_size=20),
|
|
)
|
|
@settings(max_examples=100)
|
|
def test_unique_name_generation_property(
|
|
self, base_name: str, existing_names: set[str]
|
|
) -> None:
|
|
"""
|
|
Feature: vrm-body-mesh-separator, Property 6: 唯一命名保证
|
|
For any base name and set of existing names, generate_unique_name
|
|
returns a name that is not in the existing names set.
|
|
"""
|
|
result = generate_unique_name(base_name, existing_names)
|
|
assert result not in existing_names, (
|
|
f"generate_unique_name('{base_name}', {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"
|