From cd600f06c7276e4010869d4bcc3331f1169aa35e Mon Sep 17 00:00:00 2001 From: Jean-Christophe Date: Wed, 15 Jan 2025 18:01:53 -0500 Subject: [PATCH 1/4] test: test_stl -> test_serialize --- tests/python/test_convert.py | 80 ++++++++++++++++++ tests/python/test_serialize.py | 40 +++++++++ tests/python/test_stl.py | 147 --------------------------------- tests/python/testutils.py | 37 +++++++++ 4 files changed, 157 insertions(+), 147 deletions(-) create mode 100644 tests/python/test_convert.py create mode 100644 tests/python/test_serialize.py delete mode 100644 tests/python/test_stl.py create mode 100644 tests/python/testutils.py diff --git a/tests/python/test_convert.py b/tests/python/test_convert.py new file mode 100644 index 0000000..588527f --- /dev/null +++ b/tests/python/test_convert.py @@ -0,0 +1,80 @@ +import pytest, os +import numpy as np +import typing +import openstl + +from .testutils import sample_triangles, are_faces_equal + +@pytest.fixture +def sample_vertices_and_faces(): + # Define vertices and faces + vertices = np.array([ + [0.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + [2.0, 2.0, 2.0], + [3.0, 3.0, 3.0], + ]) + faces = np.array([ + [0, 1, 2], # Face 1 + [1, 3, 2] # Face 2 + ]) + return vertices, faces + + +def test_convert_to_vertices_and_faces_on_empty(): + empty_triangles = np.array([[]]) + vertices, faces = openstl.convert.verticesandfaces(empty_triangles) + # Test if vertices and faces are empty + assert len(vertices) == 0 + assert len(faces) == 0 + +def test_convert_to_vertices_and_faces(sample_triangles): + vertices, faces = openstl.convert.verticesandfaces(sample_triangles) + # Convert vertices to tuples to make them hashable + vertices = [tuple(vertex) for vertex in vertices] + + # Test if vertices and faces are extracted correctly + assert len(vertices) == 3 + assert len(faces) == 1000 + + # Test if each face contains three indices + for face in faces: + assert len(face) == 3 + + # Test for uniqueness of vertices + unique_vertices = set(vertices) + assert len(unique_vertices) == len(vertices) + + # Test if all indices in faces are valid + for face in faces: + for vertex_idx in face: + assert vertex_idx >= 0 + assert vertex_idx < len(vertices) + + +def test_convertToVerticesAndFaces_integration(sample_vertices_and_faces): + # Extract vertices and faces + vertices, faces = sample_vertices_and_faces + + # Convert vertices and faces to triangles + triangles = openstl.convert.triangles(vertices, faces) + + # Convert triangles back to vertices and faces + result_vertices, result_faces = openstl.convert.verticesandfaces(triangles) + + # Check if the number of vertices and faces are preserved + assert len(vertices) == len(result_vertices) + assert len(faces) == len(result_faces) + + # Check if each vertices are preserved. + found_set: list[int] = [] + for i, result_vertex in enumerate(result_vertices): + for ref_vertex in vertices: + if (ref_vertex == result_vertex).all(): + found_set.append(i) + break + assert len(found_set) == result_vertices.shape[0] + + # Check if each face is correctly preserved + for face, result_face in zip(faces, result_faces): + assert are_faces_equal(face, result_face, vertices, result_vertices) \ No newline at end of file diff --git a/tests/python/test_serialize.py b/tests/python/test_serialize.py new file mode 100644 index 0000000..598dd86 --- /dev/null +++ b/tests/python/test_serialize.py @@ -0,0 +1,40 @@ +import pytest, os +import numpy as np +import typing +import openstl +import gc + +from .testutils import sample_triangles + +def test_get_vertices(sample_triangles): + vertices = sample_triangles + assert vertices.shape == (len(vertices),4,3) + assert np.allclose(vertices[0,0], [0, 0, 1]) + assert np.allclose(vertices[0,1], [1, 1, 1]) + assert np.allclose(vertices[-1,-1], [3, 3, 3]) + +def test_write_and_read(sample_triangles): + gc.disable() + filename = "test.stl" + + # Write triangles to file + assert openstl.write(filename, sample_triangles, openstl.format.ascii) + + # Read triangles from file + triangles_read = openstl.read(filename) + gc.collect() + assert len(triangles_read) == len(sample_triangles) + for i in range(len(triangles_read)): + assert np.allclose(triangles_read[i], sample_triangles[i]) # Will compare normal and vertices + + # Clean up + os.remove(filename) + +def test_fail_on_read(): + filename = "donoexist.stl" + triangles_read = openstl.read(filename) + assert len(triangles_read) == 0 + + +if __name__ == "__main__": + pytest.main() \ No newline at end of file diff --git a/tests/python/test_stl.py b/tests/python/test_stl.py deleted file mode 100644 index 46bfde5..0000000 --- a/tests/python/test_stl.py +++ /dev/null @@ -1,147 +0,0 @@ -import pytest, os -import numpy as np -import typing -import openstl -import gc - -@pytest.fixture -def sample_triangles(): - triangle = np.array([[0, 0, 1], [1, 1, 1], [2, 2, 2], [3, 3, 3]]) - return np.stack([triangle]*1000) - -def test_get_vertices(sample_triangles): - vertices = sample_triangles - assert vertices.shape == (len(vertices),4,3) - assert np.allclose(vertices[0,0], [0, 0, 1]) - assert np.allclose(vertices[0,1], [1, 1, 1]) - assert np.allclose(vertices[-1,-1], [3, 3, 3]) - -def test_write_and_read(sample_triangles): - gc.disable() - filename = "test.stl" - - # Write triangles to file - assert openstl.write(filename, sample_triangles, openstl.format.ascii) - - # Read triangles from file - triangles_read = openstl.read(filename) - gc.collect() - assert len(triangles_read) == len(sample_triangles) - for i in range(len(triangles_read)): - assert np.allclose(triangles_read[i], sample_triangles[i]) # Will compare normal and vertices - - # Clean up - os.remove(filename) - -def test_fail_on_read(): - filename = "donoexist.stl" - triangles_read = openstl.read(filename) - assert len(triangles_read) == 0 - - -# Define Face and Vec3 as tuples -Face = typing.Tuple[int, int, int] # v0, v1, v2 -Vec3 = typing.Tuple[float, float, float] - -def are_all_unique(arr: list) -> bool: - """Check if all elements in the array are unique.""" - seen = set() - for element in arr: - if element in seen: - return False - seen.add(element) - return True - - -def are_faces_equal(face1: Face, face2: Face, v1: typing.List[Vec3], v2: typing.List[Vec3]) -> bool: - """Check if two Face objects are equal.""" - # Vertices v0, v1, v2 can be shuffled between two equal faces - assert len(np.unique(face1)) == len(np.unique(face2)) - for i in face1: - if not any((v1[i] == v2[j]).all() for j in face2): - return False - return True - - -def all_faces_valid(faces: typing.List[Face], final_faces: typing.List[Face], - vertices: typing.List[Vec3], final_vertices: typing.List[Vec3]) -> bool: - """Check if all original faces are present in the final faces.""" - return all(any(are_faces_equal(face, final_f, vertices, final_vertices) for final_f in final_faces) for face in faces) - - -@pytest.fixture -def sample_vertices_and_faces(): - # Define vertices and faces - vertices = np.array([ - [0.0, 0.0, 0.0], - [1.0, 1.0, 1.0], - [2.0, 2.0, 2.0], - [3.0, 3.0, 3.0], - ]) - faces = np.array([ - [0, 1, 2], # Face 1 - [1, 3, 2] # Face 2 - ]) - return vertices, faces - - -def test_convert_to_vertices_and_faces_on_empty(): - empty_triangles = np.array([[]]) - vertices, faces = openstl.convert.verticesandfaces(empty_triangles) - # Test if vertices and faces are empty - assert len(vertices) == 0 - assert len(faces) == 0 - -def test_convert_to_vertices_and_faces(sample_triangles): - vertices, faces = openstl.convert.verticesandfaces(sample_triangles) - # Convert vertices to tuples to make them hashable - vertices = [tuple(vertex) for vertex in vertices] - - # Test if vertices and faces are extracted correctly - assert len(vertices) == 3 - assert len(faces) == 1000 - - # Test if each face contains three indices - for face in faces: - assert len(face) == 3 - - # Test for uniqueness of vertices - unique_vertices = set(vertices) - assert len(unique_vertices) == len(vertices) - - # Test if all indices in faces are valid - for face in faces: - for vertex_idx in face: - assert vertex_idx >= 0 - assert vertex_idx < len(vertices) - - -def test_convertToVerticesAndFaces_integration(sample_vertices_and_faces): - # Extract vertices and faces - vertices, faces = sample_vertices_and_faces - - # Convert vertices and faces to triangles - triangles = openstl.convert.triangles(vertices, faces) - - # Convert triangles back to vertices and faces - result_vertices, result_faces = openstl.convert.verticesandfaces(triangles) - - # Check if the number of vertices and faces are preserved - assert len(vertices) == len(result_vertices) - assert len(faces) == len(result_faces) - - # Check if each vertices are preserved. - found_set: list[int] = [] - for i, result_vertex in enumerate(result_vertices): - for ref_vertex in vertices: - if (ref_vertex == result_vertex).all(): - found_set.append(i) - break - assert len(found_set) == result_vertices.shape[0] - - # Check if each face is correctly preserved - for face, result_face in zip(faces, result_faces): - assert are_faces_equal(face, result_face, vertices, result_vertices) - -if __name__ == "__main__": - pytest.main() \ No newline at end of file diff --git a/tests/python/testutils.py b/tests/python/testutils.py new file mode 100644 index 0000000..992ab88 --- /dev/null +++ b/tests/python/testutils.py @@ -0,0 +1,37 @@ +import typing +import numpy as np +import pytest + +# Define Face and Vec3 as tuples +Face = typing.Tuple[int, int, int] # v0, v1, v2 +Vec3 = typing.Tuple[float, float, float] + +@pytest.fixture +def sample_triangles(): + triangle = np.array([[0, 0, 1], [1, 1, 1], [2, 2, 2], [3, 3, 3]]) + return np.stack([triangle]*1000) + +def are_all_unique(arr: list) -> bool: + """Check if all elements in the array are unique.""" + seen = set() + for element in arr: + if element in seen: + return False + seen.add(element) + return True + + +def are_faces_equal(face1: Face, face2: Face, v1: typing.List[Vec3], v2: typing.List[Vec3]) -> bool: + """Check if two Face objects are equal.""" + # Vertices v0, v1, v2 can be shuffled between two equal faces + assert len(np.unique(face1)) == len(np.unique(face2)) + for i in face1: + if not any((v1[i] == v2[j]).all() for j in face2): + return False + return True + + +def all_faces_valid(faces: typing.List[Face], final_faces: typing.List[Face], + vertices: typing.List[Vec3], final_vertices: typing.List[Vec3]) -> bool: + """Check if all original faces are present in the final faces.""" + return all(any(are_faces_equal(face, final_f, vertices, final_vertices) for final_f in final_faces) for face in faces) \ No newline at end of file From 7e558220512785c2a83afd9ef00bb3d3d3695e97 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Date: Wed, 15 Jan 2025 18:02:09 -0500 Subject: [PATCH 2/4] build: C++11 to C++17 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e25b40..353e3e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,7 +20,7 @@ ReadVersion(${PYPROJECT_PATH}) #------------------------------------------------------------------------------- # COMPILATION #------------------------------------------------------------------------------- -set(CMAKE_CXX_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) From 5fd8a2bc3c71d0bf8c73d4262aeaefbc248d3f12 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Date: Wed, 15 Jan 2025 18:02:43 -0500 Subject: [PATCH 3/4] feat: introduce connected components to find disjoint meshes (solids) --- README.md | 102 +++++++++++++++++- modules/core/include/openstl/core/stl.h | 72 ++++++++++++- python/core/src/stl.cpp | 39 +++++++ .../{utilities.test.cpp => convert.test.cpp} | 0 tests/core/src/disjointsets.test.cpp | 92 ++++++++++++++++ tests/python/test_topology.py | 87 +++++++++++++++ 6 files changed, 387 insertions(+), 5 deletions(-) rename tests/core/src/{utilities.test.cpp => convert.test.cpp} (100%) create mode 100644 tests/core/src/disjointsets.test.cpp create mode 100644 tests/python/test_topology.py diff --git a/README.md b/README.md index 2ef090f..23f4104 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,40 @@ The fastest and most intuitive library to manipulate STL files (stereolithograph 🌟 :fist_raised: Please consider starring and sponsoring the GitHub repo to show your support! :fist_raised: 🌟 ![GitHub Sponsor](https://img.shields.io/github/sponsors/Innoptech?label=Sponsor&logo=GitHub) +## Index +1. **Performance** + - [Performance Benchmark](#performances-benchmark) + +2. **Python Usage** + - [Install](#install) + - [Read and Write STL Files](#read-and-write-from-a-stl-file) + - [Rotate, Translate, and Scale Meshes](#rotate-translate-and-scale-a-mesh) + - [Convert Between Triangles and Vertices/Faces](#convert-triangles-arrow_right-vertices-and-faces) + - [Find Connected Components](#find-connected-components-in-mesh-topology-disjoint-solids) + - [Use with PyTorch](#use-with-pytorch) + - [Handling Large STL Files](#read-large-stl-file) + +3. **C++ Usage** + - [Read STL from File](#read-stl-from-file) + - [Write STL to File](#write-stl-to-a-file) + - [Serialize STL to Stream](#serialize-stl-to-a-stream) + - [Convert Between Triangles and Vertices/Faces](#convert-triangles-arrow_right-vertices-and-faces-1) + - [Find Connected Components](#find-connected-components-in-mesh-topology) + +4. **C++ Integration** + - [Smart Method with CMake](#smart-method) + - [Naïve Method](#naïve-method) + +5. **Testing** + - [Run Tests](#test) + +6. **Requirements** + - [C++ Standards](#requirements) + +7. **Disclaimer** + - [STL File Format Limitations](#disclaimer-stl-file-format) + + # Performances benchmark Discover the staggering performance of OpenSTL in comparison to [numpy-stl](https://github.com/wolph/numpy-stl), [meshio](https://github.com/nschloe/meshio) and [stl-reader](https://github.com/pyvista/stl-reader), thanks to its powerful C++ backend. @@ -124,6 +158,35 @@ faces = [ triangles = openstl.convert.triangles(vertices, faces) ``` +### Find Connected Components in Mesh Topology (Disjoint solids) +```python +import openstl + +# Define vertices and faces for two disconnected components +vertices = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [2.0, 2.0, 0.0], + [3.0, 2.0, 0.0], + [2.5, 3.0, 0.0], +] + +faces = [ + [0, 1, 2], # Component 1 + [3, 4, 5], # Component 2 +] + +# Identify connected components of faces +connected_components = openstl.topology.find_connected_components(vertices, faces) + +# Print the result +print(f"Number of connected components: {len(connected_components)}") +for i, component in enumerate(connected_components): + print(f"Component {i + 1}: {component}") +``` + + ### Use with `Pytorch` ```python import openstl @@ -148,7 +211,7 @@ scale = 1000.0 quad[:,1:4,:] *= scale # Avoid scaling normals ``` -### Read large STL file +### Read large STL file To read STL file with a large triangle count > **1 000 000**, the openstl buffer overflow safety must be unactivated with `openstl.set_activate_overflow_safety(False)` after import. Deactivating overflow safety may expose the application to a potential buffer overflow attack vector since the stl standard is not backed by a checksum. @@ -223,7 +286,40 @@ std::vector faces = { const auto& triangles = convertToTriangles(vertices, faces); ``` -# Integrate to your codebase +### Find Connected Components in Mesh Topology +```c++ +#include +#include +#include + +using namespace openstl; + +int main() { + std::vector vertices = { + {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, // Component 1 + {2.0f, 2.0f, 0.0f}, {3.0f, 2.0f, 0.0f}, {2.5f, 3.0f, 0.0f} // Component 2 + }; + + std::vector faces = { + {0, 1, 2}, // Component 1 + {3, 4, 5}, // Component 2 + }; + + const auto& connected_components = findConnectedComponents(vertices, faces); + + std::cout << "Number of connected components: " << connected_components.size() << "\\n"; + for (size_t i = 0; i < connected_components.size(); ++i) { + std::cout << "Component " << i + 1 << ":\\n"; + for (const auto& face : connected_components[i]) { + std::cout << " {" << face[0] << ", " << face[1] << ", " << face[2] << "}\\n"; + } + } + + return 0; +} +``` +**** +# Integrate to your C++ codebase ### Smart method Include this repository with CMAKE Fetchcontent and link your executable/library to `openstl::core` library. Choose weither you want to fetch a specific branch or tag using `GIT_TAG`. Use the `main` branch to keep updated with the latest improvements. @@ -250,7 +346,7 @@ ctest . ``` # Requirements -C++11 or higher. +C++17 or higher. # DISCLAIMER: STL File Format # diff --git a/modules/core/include/openstl/core/stl.h b/modules/core/include/openstl/core/stl.h index 6364841..c6ecb0d 100644 --- a/modules/core/include/openstl/core/stl.h +++ b/modules/core/include/openstl/core/stl.h @@ -133,7 +133,7 @@ namespace openstl * A library-level configuration to activate/deactivate the buffer overflow safety * @return */ - bool& activateOverflowSafety() { + inline bool& activateOverflowSafety() { static bool safety_enabled = true; return safety_enabled; } @@ -284,7 +284,7 @@ namespace openstl } //--------------------------------------------------------------------------------------------------------- - // Transformation Utils + // Conversion Utils //--------------------------------------------------------------------------------------------------------- using Face = std::array; // v0, v1, v2 @@ -389,5 +389,73 @@ namespace openstl } return triangles; } + + //--------------------------------------------------------------------------------------------------------- + // Topology Utils + //--------------------------------------------------------------------------------------------------------- + /** + * DisjointSet class to manage disjoint sets with union-find. + */ + class DisjointSet { + std::vector parent; + std::vector rank; + + public: + explicit DisjointSet(size_t size) : parent(size), rank(size, 0) { + for (size_t i = 0; i < size; ++i) parent[i] = i; + } + + size_t find(size_t x) { + if (parent[x] != x) parent[x] = find(parent[x]); + return parent[x]; + } + + void unite(size_t x, size_t y) { + size_t rootX = find(x), rootY = find(y); + if (rootX != rootY) { + if (rank[rootX] < rank[rootY]) parent[rootX] = rootY; + else if (rank[rootX] > rank[rootY]) parent[rootY] = rootX; + else { + parent[rootY] = rootX; + ++rank[rootX]; + } + } + } + + bool connected(size_t x, size_t y) { + return find(x) == find(y); + } + }; + + /** + * Identifies and groups connected components of faces based on shared vertices. + * + * @param vertices A container of vertices. + * @param faces A container of faces, where each face is a collection of vertex indices. + * @return A vector of connected components, where each component is a vector of faces. + */ + template + inline std::vector> + findConnectedComponents(const ContainerA& vertices, const ContainerB& faces) { + DisjointSet ds{vertices.size()}; + for (const auto& tri : faces) { + ds.unite(tri[0], tri[1]); + ds.unite(tri[0], tri[2]); + } + + std::vector> result; + std::unordered_map rootToIndex; + + for (const auto& tri : faces) { + size_t root = ds.find(tri[0]); + if (rootToIndex.find(root) == rootToIndex.end()) { + rootToIndex[root] = result.size(); + result.emplace_back(); + } + result[rootToIndex[root]].push_back(tri); + } + return result; + } + } //namespace openstl #endif //OPENSTL_OPENSTL_SERIALIZE_H diff --git a/python/core/src/stl.cpp b/python/core/src/stl.cpp index 38aed25..f02bbdb 100644 --- a/python/core/src/stl.cpp +++ b/python/core/src/stl.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -241,9 +242,47 @@ void convertSubmodule(py::module_ &_m) }, "vertices"_a,"faces"_a, "Convert the mesh from vertices and faces to triangles"); } +void topologySubmodule(py::module_ &_m) +{ + auto m = _m.def_submodule("topology", "A submodule for analyzing and segmenting connected components in mesh topology."); + + m.def("find_connected_components", []( + const py::array_t &vertices, + const py::array_t &faces + ) -> std::vector> + { + py::scoped_ostream_redirect stream(std::cerr,py::module_::import("sys").attr("stderr")); + auto vbuf = py::array_t::ensure(vertices); + if(!vbuf){ + std::cerr << "Vertices input array cannot be interpreted as a mesh.\n"; + return {}; + } + if (vbuf.ndim() != 2 || vbuf.shape(1) != 3){ + std::cerr << "Vertices input array cannot be interpreted as a mesh. Shape must be N x 3.\n"; + return {}; + } + + auto fbuf = py::array_t::ensure(faces); + if(!fbuf){ + std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; + return {}; + } + if (fbuf.ndim() != 2 || vbuf.shape(1) != 3){ + std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; + std::cerr << "Shape must be N x 3 (v0, v1, v2).\n"; + return {}; + } + + StridedSpan verticesIter{vbuf.data(), (size_t)vbuf.shape(0)}; + StridedSpan facesIter{fbuf.data(), (size_t)fbuf.shape(0)}; + return findConnectedComponents(verticesIter, facesIter); + }, "vertices"_a,"faces"_a, "Convert the mesh from vertices and faces to triangles"); +} + PYBIND11_MODULE(openstl, m) { serialize(m); convertSubmodule(m); + topologySubmodule(m); m.attr("__version__") = OPENSTL_PROJECT_VER; m.doc() = "A simple STL serializer and deserializer"; diff --git a/tests/core/src/utilities.test.cpp b/tests/core/src/convert.test.cpp similarity index 100% rename from tests/core/src/utilities.test.cpp rename to tests/core/src/convert.test.cpp diff --git a/tests/core/src/disjointsets.test.cpp b/tests/core/src/disjointsets.test.cpp new file mode 100644 index 0000000..e878320 --- /dev/null +++ b/tests/core/src/disjointsets.test.cpp @@ -0,0 +1,92 @@ +#include +#include "openstl/core/stl.h" + +using namespace openstl; + +TEST_CASE("DisjointSet basic operations", "[DisjointSet]") { + DisjointSet ds(10); + + SECTION("Initial state") { + for (size_t i = 0; i < 10; ++i) { + REQUIRE(ds.find(i) == i); + } + } + + SECTION("Union operation") { + ds.unite(0, 1); + ds.unite(2, 3); + ds.unite(1, 3); + + REQUIRE(ds.connected(0, 3)); + REQUIRE(ds.connected(1, 2)); + REQUIRE(!ds.connected(0, 4)); + } + + SECTION("Find with path compression") { + ds.unite(4, 5); + ds.unite(5, 6); + REQUIRE(ds.find(6) == ds.find(4)); + REQUIRE(ds.find(5) == ds.find(4)); + } + + SECTION("Disconnected sets") { + ds.unite(7, 8); + REQUIRE(!ds.connected(7, 9)); + REQUIRE(ds.connected(7, 8)); + } +} + +TEST_CASE("Find connected components of faces", "[findConnectedComponents]") { + std::vector> vertices = { + {0.0f, 0.0f, 0.0f}, + {1.0f, 0.0f, 0.0f}, + {0.0f, 1.0f, 0.0f}, + {1.0f, 1.0f, 0.0f}, + {0.5f, 0.5f, 1.0f}, + }; + + std::vector> faces = { + {0, 1, 2}, + {1, 3, 2}, + {2, 3, 4}, + }; + + SECTION("Single connected component") { + auto connectedComponents = findConnectedComponents(vertices, faces); + REQUIRE(connectedComponents.size() == 1); + REQUIRE(connectedComponents[0].size() == 3); + } + + SECTION("Multiple disconnected components") { + faces.push_back({5, 6, 7}); + vertices.push_back({2.0f, 2.0f, 0.0f}); + vertices.push_back({3.0f, 2.0f, 0.0f}); + vertices.push_back({2.5f, 3.0f, 0.0f}); + + auto connectedComponents = findConnectedComponents(vertices, faces); + REQUIRE(connectedComponents.size() == 2); + REQUIRE(connectedComponents[0].size() == 3); + REQUIRE(connectedComponents[1].size() == 1); + } + + SECTION("No faces provided") { + faces.clear(); + auto connectedComponents = findConnectedComponents(vertices, faces); + REQUIRE(connectedComponents.empty()); + } + + SECTION("Single face") { + faces = {{0, 1, 2}}; + auto connectedComponents = findConnectedComponents(vertices, faces); + REQUIRE(connectedComponents.size() == 1); + REQUIRE(connectedComponents[0].size() == 1); + REQUIRE(connectedComponents[0][0] == std::array{0, 1, 2}); + } + + SECTION("Disconnected vertices") { + vertices.push_back({10.0f, 10.0f, 10.0f}); // Add an isolated vertex + auto connectedComponents = findConnectedComponents(vertices, faces); + REQUIRE(connectedComponents.size() == 1); + REQUIRE(connectedComponents[0].size() == 3); // Only faces contribute + } +} \ No newline at end of file diff --git a/tests/python/test_topology.py b/tests/python/test_topology.py new file mode 100644 index 0000000..05b2e89 --- /dev/null +++ b/tests/python/test_topology.py @@ -0,0 +1,87 @@ +import numpy as np +import pytest +from openstl.topology import find_connected_components + +@pytest.fixture +def sample_vertices_and_faces(): + vertices = np.array([ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [0.5, 0.5, 1.0], + ]) + faces = np.array([ + [0, 1, 2], + [1, 3, 2], + [2, 3, 4], + ]) + return vertices, faces + + +def test_single_connected_component(sample_vertices_and_faces): + vertices, faces = sample_vertices_and_faces + connected_components = find_connected_components(vertices, faces) + + # Expect one connected component containing all faces + assert len(connected_components) == 1 + assert len(connected_components[0]) == 3 + + +def test_multiple_disconnected_components(sample_vertices_and_faces): + vertices, faces = sample_vertices_and_faces + # Add disconnected component + faces = np.vstack([faces, [5, 6, 7]]) + vertices = np.vstack([ + vertices, + [2.0, 2.0, 0.0], + [3.0, 2.0, 0.0], + [2.5, 3.0, 0.0] + ]) + + connected_components = find_connected_components(vertices, faces) + + # Expect two connected components + assert len(connected_components) == 2 + assert len(connected_components[0]) == 3 + assert len(connected_components[1]) == 1 + + +def test_no_faces(): + vertices = np.array([ + [0.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + ]) + faces = np.array([]).reshape(0, 3) + + connected_components = find_connected_components(vertices, faces) + + # Expect no connected components + assert len(connected_components) == 0 + + +def test_single_face(): + vertices = np.array([ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + ]) + faces = np.array([[0, 1, 2]]) + + connected_components = find_connected_components(vertices, faces) + + # Expect one connected component with one face + assert len(connected_components) == 1 + assert len(connected_components[0]) == 1 + assert np.array_equal(connected_components[0][0], [0, 1, 2]) + + +def test_disconnected_vertices(sample_vertices_and_faces): + vertices, faces = sample_vertices_and_faces + vertices = np.vstack([vertices, [10.0, 10.0, 10.0]]) # Add disconnected vertex + + connected_components = find_connected_components(vertices, faces) + + # Expect one connected component (disconnected vertex ignored) + assert len(connected_components) == 1 + assert len(connected_components[0]) == 3 # Only faces contribute From b7c5580c0d0327de023617b75f33209a08a4f4fd Mon Sep 17 00:00:00 2001 From: Jean-Christophe Date: Wed, 15 Jan 2025 18:03:07 -0500 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=201.2.10=20=E2=86=92=201.3.?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 38e45b5..9b2ae47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ testpaths = ["tests/python"] [tool.commitizen] name = "cz_conventional_commits" -version = "1.2.10" +version = "1.3.0" tag_format = "v$version" [tool.cibuildwheel]