diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index 1fd6465..787ca28 100644 --- a/src/readyplayerme/meshops/mesh.py +++ b/src/readyplayerme/meshops/mesh.py @@ -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: @@ -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]) diff --git a/src/readyplayerme/meshops/types.py b/src/readyplayerme/meshops/types.py new file mode 100644 index 0000000..aee2060 --- /dev/null +++ b/src/readyplayerme/meshops/types.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 5e2448b..c072230 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""Pytest fixtures for the whole repo.""" from pathlib import Path import pytest diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py new file mode 100644 index 0000000..5e09a08 --- /dev/null +++ b/tests/mocks/__init__.py @@ -0,0 +1 @@ +"""Module holding data-files for resource mocks.""" diff --git a/tests/readyplayerme/meshops/conftest.py b/tests/readyplayerme/meshops/conftest.py new file mode 100644 index 0000000..442c6eb --- /dev/null +++ b/tests/readyplayerme/meshops/conftest.py @@ -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") diff --git a/tests/readyplayerme/meshops/integration/test_integration.py b/tests/readyplayerme/meshops/integration/test_integration.py new file mode 100644 index 0000000..323ae6e --- /dev/null +++ b/tests/readyplayerme/meshops/integration/test_integration.py @@ -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." diff --git a/tests/readyplayerme/meshops/test_mesh.py b/tests/readyplayerme/meshops/test_mesh.py deleted file mode 100644 index 0287e3b..0000000 --- a/tests/readyplayerme/meshops/test_mesh.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Unit tests for the mesh module.""" -from pathlib import Path - -from readyplayerme.meshops import mesh - -mocks_path = Path(__file__).parent.parent.parent / "mocks" - - -def test_read_glb(): - """Test the read_glb function returns the expected type.""" - filename = mocks_path / "input-mesh.glb" - result = mesh.read_glb(filename) - assert isinstance(result, mesh.Mesh), "The result should be an instance of mesh.Mesh" - - -def test_border_vertices(): - """Test the get_border_vertices function returns the expected indices.""" - import numpy as np - - # TODO: create a fixture for loading the file, or mock it, loading the mesh should not be part of the test! - filename = mocks_path / "uv-seams.glb" - geo = mesh.read_glb(filename) - border_vertices = mesh.get_border_vertices(geo) - - assert np.array_equiv(np.sort(border_vertices), [0, 2, 4, 6, 7, 9, 10]) diff --git a/tests/readyplayerme/meshops/unit/conftest.py b/tests/readyplayerme/meshops/unit/conftest.py new file mode 100644 index 0000000..fc9f008 --- /dev/null +++ b/tests/readyplayerme/meshops/unit/conftest.py @@ -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) diff --git a/tests/readyplayerme/meshops/unit/test_mesh.py b/tests/readyplayerme/meshops/unit/test_mesh.py new file mode 100644 index 0000000..eaaa655 --- /dev/null +++ b/tests/readyplayerme/meshops/unit/test_mesh.py @@ -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."