feat(body_mesh): Add edge UV data for facial and hair mesh components

- Add edge UV coordinate data for N00_000_00_FaceEyelash_00_FACE mesh instance with 112 edges
- Add edge UV coordinate data for N00_000_00_HairBack_00_HAIR mesh instance with 800 edges
- Update body_mesh module initialization and operations to support new mesh data
- Enhance panel UI and utility functions for mesh edge UV handling
- Add comprehensive UV matching test suite for validation
- Update registration module to include new mesh components
- Improve test utilities for edge UV data verification
This commit is contained in:
2026-01-02 13:32:03 +08:00
parent f2257449de
commit 21222044c4
10 changed files with 10147 additions and 58 deletions

View File

@@ -3,11 +3,19 @@
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 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:
@@ -58,3 +66,431 @@ class TestGenerateUniqueName:
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"