Skip to content

Commit

Permalink
Merge pull request #3 from readyplayerme/feat/identify-border-vertices
Browse files Browse the repository at this point in the history
refactor!: tests and fixtures
  • Loading branch information
TechyDaniel authored Nov 15, 2023
2 parents fbbb8d2 + d38f1e7 commit 4eb44a4
Show file tree
Hide file tree
Showing 9 changed files with 217 additions and 39 deletions.
27 changes: 13 additions & 14 deletions src/readyplayerme/meshops/mesh.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
"""Functions to handle mesh data and read it from file."""
from collections.abc import Callable
from pathlib import Path
from typing import TypeAlias

import numpy as np
import numpy.typing as npt
import trimesh

# Abstraction for the mesh type.
Mesh: TypeAlias = trimesh.Trimesh
from readyplayerme.meshops.types import Edges, Indices, Mesh


def read_mesh(filename: str | Path) -> Mesh:
Expand All @@ -24,27 +21,29 @@ def read_mesh(filename: str | Path) -> Mesh:
def get_mesh_reader(filename: str | Path) -> Callable[[str | Path], Mesh]:
"""Return a reader function for a given file extension."""
if (ext := Path(filename).suffix) in [".glb", ".gltf"]:
return read_glb
return read_gltf
msg = f"Unsupported file format: {ext}"
raise NotImplementedError(msg)


def read_glb(filename: str | Path) -> Mesh:
"""Load 3D model data from a GLB file into a Mesh representation.
def read_gltf(filename: str | Path) -> Mesh:
"""Load 3D model data from a glTF file into a Mesh representation.
:param filename: The path to the GLB file to be loaded.
:param filename: The path to the glTF file to be loaded.
:return: The loaded mesh object.
"""
return trimesh.load(filename, process=False, force="mesh")


def get_border_vertices(mesh: Mesh) -> npt.NDArray[np.int64]:
"""Return the indices of the vertices on the borders of a mesh.
def get_boundary_vertices(edges: Edges) -> Indices:
"""Return the indices of the vertices on the mesh boundary.
:param mesh: The mesh to get the border vertices from.
:return: The indices of the border vertices.
A boundary edge is an edge that only belongs to a single triangle.
:param edges: The edges of the mesh. Must include all edges by faces (duplicates).
:return: Vertex indices on mesh boundary.
"""
edges = mesh.edges_sorted
unique_edges, edge_triangle_count = np.unique(edges, return_counts=True, axis=0)
sorted_edges = np.sort(edges, axis=1)
unique_edges, edge_triangle_count = np.unique(sorted_edges, return_counts=True, axis=0)
border_edge_indices = np.where(edge_triangle_count == 1)[0]
return np.unique(unique_edges[border_edge_indices])
22 changes: 22 additions & 0 deletions src/readyplayerme/meshops/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Custom types for meshops."""
from typing import Protocol, TypeAlias

import numpy as np
import numpy.typing as npt

# trimesh uses int64 and float64 for its arrays.
Indices: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (i,)
Vertices: TypeAlias = npt.NDArray[np.float32] | npt.NDArray[np.float64] # Shape (v, 3)
Edges: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (e, 2)
Faces: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (f, 3)


class Mesh(Protocol):
"""Structural type for a mesh class.
Any class considered a mesh must structurally be compatible with this protocol.
"""

vertices: Vertices
edges: Edges
faces: Faces
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Pytest fixtures for the whole repo."""
from pathlib import Path

import pytest
Expand Down
1 change: 1 addition & 0 deletions tests/mocks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Module holding data-files for resource mocks."""
12 changes: 12 additions & 0 deletions tests/readyplayerme/meshops/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Pytest fixtures for meshops tests."""
import pytest


@pytest.fixture
def gltf_simple_file():
"""Return a path to a simple glTF file."""
from importlib.resources import files

import tests.mocks

return files(tests.mocks).joinpath("uv-seams.glb")
15 changes: 15 additions & 0 deletions tests/readyplayerme/meshops/integration/test_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pathlib import Path

import numpy as np

import readyplayerme.meshops.mesh as mops


def test_boundary_vertices_from_file(gltf_simple_file: str | Path):
"""Test the integration of extracting mesh boundary vertices from a file."""
local_mesh = mops.read_mesh(gltf_simple_file)
edges = local_mesh.edges
boundary_vertices = mops.get_boundary_vertices(edges)
assert np.array_equiv(
np.sort(boundary_vertices), [0, 2, 4, 6, 7, 9, 10]
), "The vertices returned by get_border_vertices do not match the expected vertices."
25 changes: 0 additions & 25 deletions tests/readyplayerme/meshops/test_mesh.py

This file was deleted.

94 changes: 94 additions & 0 deletions tests/readyplayerme/meshops/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Pytest fixtures for meshops unit tests."""
from dataclasses import dataclass

import numpy as np
import numpy.typing as npt
import pytest


@pytest.fixture
def mock_mesh():
"""Return a mocked instance of a mesh."""

@dataclass
class MockMesh:
vertices: npt.NDArray[np.float32]
edges: npt.NDArray[np.int32]
faces: npt.NDArray[np.int32]

vertices = np.array(
[
[-1.0, -1.0, 1.0],
[-1.0, 1.0, 1.0],
[-1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, -1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, -1.0, -1.0],
[1.0, 1.0, -1.0],
[1.0, 0.0, 1.0],
[1.0, 0.0, -1.0],
[1.0, 0.0, -1.0],
]
)
edges = np.array(
[
[9, 2],
[2, 3],
[3, 9],
[3, 7],
[7, 9],
[9, 3],
[7, 8],
[8, 10],
[10, 7],
[8, 5],
[5, 1],
[1, 8],
[3, 5],
[5, 7],
[7, 3],
[1, 2],
[2, 0],
[0, 1],
[3, 1],
[1, 5],
[5, 3],
[9, 6],
[6, 2],
[2, 9],
[1, 3],
[3, 2],
[2, 1],
[7, 5],
[5, 8],
[8, 7],
[8, 0],
[0, 4],
[4, 8],
[10, 8],
[8, 4],
[4, 10],
[8, 1],
[1, 0],
[0, 8],
]
)
faces = np.array(
[
[9, 2, 3],
[3, 7, 9],
[7, 8, 10],
[8, 5, 1],
[3, 5, 7],
[1, 2, 0],
[3, 1, 5],
[9, 6, 2],
[1, 3, 2],
[7, 5, 8],
[8, 0, 4],
[10, 8, 4],
[8, 1, 0],
]
)
return MockMesh(vertices=vertices, edges=edges, faces=faces)
59 changes: 59 additions & 0 deletions tests/readyplayerme/meshops/unit/test_mesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Unit tests for the mesh module."""
import types
from collections.abc import Callable
from pathlib import Path
from typing import Union, get_args, get_origin

import numpy as np
import pytest

from readyplayerme.meshops import mesh
from readyplayerme.meshops.types import Mesh


class TestReadMesh:
"""Test suite for the mesh reader functions."""

@pytest.mark.parametrize(
"filepath, expected",
[("test.glb", mesh.read_gltf), (Path("test.glb"), mesh.read_gltf), ("test.gltf", mesh.read_gltf)],
)
def test_get_mesh_reader_glb(self, filepath: str | Path, expected: Callable[[str | Path], Mesh]):
"""Test the get_mesh_reader function with a .glb file path."""
reader = mesh.get_mesh_reader(filepath)
assert callable(reader), "The mesh reader should be a callable function."
assert reader == expected, "The reader function for .glTF files should be read_gltf."

@pytest.mark.parametrize("filepath", ["test", "test.obj", Path("test.stl"), "test.fbx", "test.abc", "test.ply"])
def test_get_mesh_reader_unsupported(self, filepath: str | Path):
"""Test the get_mesh_reader function with an unsupported file format."""
with pytest.raises(NotImplementedError):
mesh.get_mesh_reader(filepath)

def test_read_mesh_gltf(self, gltf_simple_file: str | Path):
"""Test the read_gltf function returns the expected type."""
result = mesh.read_gltf(gltf_simple_file)
# Check the result has all the expected attributes and of the correct type.
for attr in Mesh.__annotations__:
assert hasattr(result, attr), f"The mesh class should have a '{attr}' attribute."
# Find the type so we can use it as a second argument to isinstance.
tp = get_origin(Mesh.__annotations__[attr])
if tp is Union or tp is types.UnionType:
tp = get_args(Mesh.__annotations__[attr])
# Loop through the types in the union and check if the result is compatible with any of them.
assert any(
isinstance(getattr(result, attr), get_origin(t)) for t in tp
), f"The '{attr}' attribute should be compatible with {tp}."
else:
assert isinstance(
getattr(result, attr), tp
), f"The '{attr}' attribute should be compatible with {Mesh.__annotations__[attr]}."


def test_get_boundary_vertices(mock_mesh: Mesh):
"""Test the get_boundary_vertices function returns the expected indices."""
boundary_vertices = mesh.get_boundary_vertices(mock_mesh.edges)

assert np.array_equiv(
np.sort(boundary_vertices), [0, 2, 4, 6, 7, 9, 10]
), "The vertices returned by get_border_vertices do not match the expected vertices."

0 comments on commit 4eb44a4

Please sign in to comment.