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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user