├── tests ├── python │ ├── __init__.py │ ├── test_serialize.py │ ├── testutils.py │ ├── test_topology.py │ └── test_convert.py ├── utils │ ├── assets │ │ ├── objects │ │ │ ├── BALL.STL │ │ │ ├── KEY.STL │ │ │ └── WASHER.STL │ │ └── assets_path.h.in │ ├── CMakeLists.txt │ └── include │ │ └── openstl │ │ └── tests │ │ └── testutils.h ├── CMakeLists.txt └── core │ ├── CMakeLists.txt │ └── src │ ├── serialize.test.cpp │ ├── disjointsets.test.cpp │ ├── convert.test.cpp │ └── deserialize.test.cpp ├── benchmark ├── benchmark.png └── benchmark.py ├── extern ├── catch2 │ └── CMakeLists.txt └── pybind11 │ └── CMakeLists.txt ├── modules └── core │ ├── include │ └── openstl │ │ └── core │ │ ├── version.h.in │ │ └── stl.h │ └── CMakeLists.txt ├── .gitignore ├── cmake └── CMakeLists.txt ├── python ├── CMakeLists.txt └── core │ └── src │ └── stl.cpp ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ ├── ci-and-bump.yml │ └── release.yml ├── CMakeLists.txt ├── setup.py └── README.md /tests/python/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /benchmark/benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innoptech/OpenSTL/HEAD/benchmark/benchmark.png -------------------------------------------------------------------------------- /tests/utils/assets/objects/BALL.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innoptech/OpenSTL/HEAD/tests/utils/assets/objects/BALL.STL -------------------------------------------------------------------------------- /tests/utils/assets/objects/KEY.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innoptech/OpenSTL/HEAD/tests/utils/assets/objects/KEY.STL -------------------------------------------------------------------------------- /tests/utils/assets/objects/WASHER.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innoptech/OpenSTL/HEAD/tests/utils/assets/objects/WASHER.STL -------------------------------------------------------------------------------- /tests/utils/assets/assets_path.h.in: -------------------------------------------------------------------------------- 1 | #ifndef OPENSTL_ASSETS_ASSETS_PATH_H 2 | #define OPENSTL_ASSETS_ASSETS_PATH_H 3 | 4 | #define OPENSTL_TEST_ASSETSDIR "@OPENSTL_TEST_ASSETSDIR@" 5 | #endif //OPENSTL_ASSETS_ASSETS_PATH_H 6 | -------------------------------------------------------------------------------- /extern/catch2/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | Include(FetchContent) 2 | FetchContent_Declare( 3 | Catch2 4 | GIT_REPOSITORY https://github.com/catchorg/Catch2.git 5 | GIT_TAG ${catch2_VERSION} 6 | GIT_SHALLOW TRUE 7 | GIT_PROGRESS TRUE 8 | ) 9 | FetchContent_MakeAvailable(Catch2) -------------------------------------------------------------------------------- /extern/pybind11/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | message(STATUS "Fetch pybind11") 2 | include(FetchContent) 3 | FetchContent_Declare( 4 | pybind11 5 | GIT_REPOSITORY https://github.com/pybind/pybind11.git 6 | GIT_TAG ${pybind11_VERSION} 7 | GIT_SHALLOW TRUE 8 | GIT_PROGRESS TRUE 9 | ) 10 | FetchContent_MakeAvailable(pybind11) -------------------------------------------------------------------------------- /modules/core/include/openstl/core/version.h.in: -------------------------------------------------------------------------------- 1 | #ifndef OPENSTL_CORE_VERSION_H 2 | #define OPENSTL_CORE_VERSION_H 3 | 4 | #define OPENSTL_PROJECT_VER "@PROJECT_VERSION@" 5 | #define OPENSTL_VER_MAJOR @PROJECT_VERSION_MAJOR@ 6 | #define OPENSTL_VER_MINOR @PROJECT_VERSION_MINOR@ 7 | #define OPENSTL_VER_PATCH @PROJECT_VERSION_PATCH@ 8 | #define OPENSTL_PROJECT_NAME "@PROJECT_NAME@" 9 | 10 | #endif //OPENSTL_CORE_VERSION_H 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *build*/ 2 | *Testing*/ 3 | 4 | # C extensions 5 | *.so 6 | *.a 7 | 8 | # Images and large files 9 | *.stl 10 | *.png 11 | 12 | # Compiled Python bytecode 13 | *.py[cod] 14 | *$py.class 15 | *.egg-info/ 16 | *.whl 17 | dist/ 18 | 19 | # IDE cache 20 | .vscode/ 21 | .eggs/ 22 | .idea/ 23 | 24 | # Cache files 25 | __pycache__ 26 | .pytest_cache/ 27 | mypy_cache/ 28 | 29 | #Ignore files generated at runtime 30 | .env 31 | build 32 | generated 33 | 34 | # Exclusions 35 | !tests/* 36 | !benchmark/benchmark.png -------------------------------------------------------------------------------- /tests/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | message(STATUS "Adding OpenSTL tests suite") 2 | 3 | #------------------------------------------------------------------------------- 4 | # Ensure Dependencies 5 | #------------------------------------------------------------------------------- 6 | if (NOT TARGET Catch2::Catch2WithMain) 7 | message( FATAL_ERROR "catch2 could not be found") 8 | endif() 9 | 10 | #------------------------------------------------------------------------------- 11 | # Add tests 12 | #------------------------------------------------------------------------------- 13 | add_subdirectory(utils) # Test utilities 14 | add_subdirectory(core) 15 | -------------------------------------------------------------------------------- /cmake/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | macro(ReadVersion version_file) 2 | file(READ ${version_file} ver) 3 | string(REGEX MATCH "version\ *=\ *\"([0-9]+).([0-9]+).([0-9]*)\"" _ ${ver}) 4 | set(PROJECT_VERSION_MAJOR ${CMAKE_MATCH_1}) 5 | set(PROJECT_VERSION_MINOR ${CMAKE_MATCH_2}) 6 | set(PROJECT_VERSION_PATCH ${CMAKE_MATCH_3}) 7 | set(PROJECT_VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") 8 | message(STATUS "${PROJECT_NAME} version: ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") 9 | endmacro() 10 | 11 | macro(ReadDependencyVersion dependency_name version_file) 12 | file(READ ${version_file} ver) 13 | string(REGEX MATCH "${dependency_name}\ *=\ *\"?([^\"]*)\"?" _ ${ver}) 14 | set(${dependency_name}_VERSION "${CMAKE_MATCH_1}") 15 | message(STATUS "${dependency_name} version: ${${dependency_name}_VERSION}") 16 | endmacro() -------------------------------------------------------------------------------- /tests/core/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------- 2 | # Ensure Dependencies 3 | #------------------------------------------------------------------------------- 4 | if (NOT TARGET openstl::testutils) 5 | message( FATAL_ERROR "openstl::testutils could not be found") 6 | endif() 7 | 8 | #------------------------------------------------------------------------------- 9 | # CMAKE CONFIGURATIONS 10 | #------------------------------------------------------------------------------- 11 | # No configuration 12 | 13 | #------------------------------------------------------------------------------- 14 | # Add test executable 15 | #------------------------------------------------------------------------------- 16 | file(GLOB_RECURSE tests_src src/*.test.cpp) 17 | add_executable(tests_core ${tests_src}) 18 | target_link_libraries(tests_core PRIVATE openstl::testutils Catch2::Catch2WithMain) 19 | target_include_directories(tests_core PRIVATE include/ ${CMAKE_CURRENT_BINARY_DIR}/generated/) 20 | catch_discover_tests(tests_core) -------------------------------------------------------------------------------- /python/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | message(STATUS "Adding OpenSTL python binding") 2 | 3 | 4 | #------------------------------------------------------------------------------- 5 | # Internal libraries 6 | #------------------------------------------------------------------------------- 7 | if (NOT TARGET openstl::core) 8 | message( FATAL_ERROR "openstl::core module could not be found") 9 | endif() 10 | 11 | #------------------------------------------------------------------------------- 12 | # Build Python Binding 13 | #------------------------------------------------------------------------------- 14 | file(GLOB_RECURSE python_SRC core/*.cpp) 15 | pybind11_add_module(openstl MODULE ${python_SRC}) 16 | target_include_directories(openstl PRIVATE ${PYBIND11_SUBMODULE}/include) 17 | target_link_libraries(openstl PRIVATE openstl::core pybind11::headers) 18 | target_compile_definitions(openstl PRIVATE VERSION_INFO=${PROJECT_VERSION}) 19 | set_target_properties(openstl PROPERTIES 20 | INTERPROCEDURAL_OPTIMIZATION ON 21 | CXX_VISIBILITY_PRESET hidden 22 | VISIBILITY_INLINES_HIDDEN ON) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Innoptech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/python/test_serialize.py: -------------------------------------------------------------------------------- 1 | import pytest, os 2 | import numpy as np 3 | import typing 4 | import openstl 5 | import gc 6 | 7 | from .testutils import sample_triangles 8 | 9 | def test_get_vertices(sample_triangles): 10 | vertices = sample_triangles 11 | assert vertices.shape == (len(vertices),4,3) 12 | assert np.allclose(vertices[0,0], [0, 0, 1]) 13 | assert np.allclose(vertices[0,1], [1, 1, 1]) 14 | assert np.allclose(vertices[-1,-1], [3, 3, 3]) 15 | 16 | def test_write_and_read(sample_triangles): 17 | gc.disable() 18 | filename = "test.stl" 19 | 20 | # Write triangles to file 21 | assert openstl.write(filename, sample_triangles, openstl.format.ascii) 22 | 23 | # Read triangles from file 24 | triangles_read = openstl.read(filename) 25 | gc.collect() 26 | assert len(triangles_read) == len(sample_triangles) 27 | for i in range(len(triangles_read)): 28 | assert np.allclose(triangles_read[i], sample_triangles[i]) # Will compare normal and vertices 29 | 30 | # Clean up 31 | os.remove(filename) 32 | 33 | def test_fail_on_read(): 34 | filename = "donoexist.stl" 35 | triangles_read = openstl.read(filename) 36 | assert len(triangles_read) == 0 37 | 38 | 39 | if __name__ == "__main__": 40 | pytest.main() -------------------------------------------------------------------------------- /tests/utils/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------- 2 | # Ensure Dependencies 3 | #------------------------------------------------------------------------------- 4 | # No dependencies 5 | 6 | #------------------------------------------------------------------------------- 7 | # CMAKE CONFIGURATIONS 8 | #------------------------------------------------------------------------------- 9 | set(OPENSTL_TEST_ASSETSDIR "${CMAKE_CURRENT_BINARY_DIR}/generated/tests/assets/") 10 | set(SOURCE_ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets") 11 | set(DEST_ASSETS_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/tests/assets") 12 | file(GLOB_RECURSE ASSET_FILES "${SOURCE_ASSETS_DIR}/*") 13 | list(FILTER ASSET_FILES EXCLUDE REGEX "\\.in$") 14 | file(COPY ${ASSET_FILES} DESTINATION ${DEST_ASSETS_DIR}) 15 | configure_file(assets/assets_path.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/tests/assets/assets_path.h) 16 | 17 | #------------------------------------------------------------------------------- 18 | # Add test executable 19 | #------------------------------------------------------------------------------- 20 | add_library(tests_utils INTERFACE) 21 | target_link_libraries(tests_utils INTERFACE openstl::core) 22 | target_include_directories(tests_utils INTERFACE include/ ${CMAKE_CURRENT_BINARY_DIR}/generated/tests/) 23 | add_library(openstl::testutils ALIAS tests_utils) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "ninja", "cmake>=3.15.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.mypy] 6 | files = "setup.py" 7 | python_version = "3.7" 8 | strict = true 9 | show_error_codes = true 10 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 11 | warn_unreachable = true 12 | 13 | [[tool.mypy.overrides]] 14 | module = ["ninja"] 15 | ignore_missing_imports = true 16 | 17 | [tool.pytest.ini_options] 18 | minversion = "6.0" 19 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 20 | xfail_strict = true 21 | filterwarnings = [ 22 | "error", 23 | "ignore:(ast.Str|Attribute s|ast.NameConstant|ast.Num) is deprecated:DeprecationWarning:_pytest", 24 | ] 25 | testpaths = ["tests/python"] 26 | 27 | [tool.commitizen] 28 | name = "cz_conventional_commits" 29 | version = "4.0.1" 30 | tag_format = "v$version" 31 | 32 | [tool.cibuildwheel] 33 | test-command = "pytest {project}/tests" 34 | test-extras = ["test"] 35 | test-skip = ["*universal2:arm64", "pp*", "cp{38,39,310,311,312}-manylinux_i686", "cp38-macosx_arm64", "*musllinux*", "*ppc64le", "*s390x", "cp{39,310,311,312,313}-win32", "cp313*"] 36 | # Setuptools bug causes collision between pypy and cpython artifacts 37 | before-build = "rm -rf {project}/build" 38 | 39 | [tool.poetry.dependencies] 40 | pybind11 = "v2.11.1" 41 | catch2 = "v3.5.3" 42 | -------------------------------------------------------------------------------- /modules/core/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | message(STATUS "Adding openstl::core module") 2 | 3 | #------------------------------------------------------------------------------- 4 | # Ensure requirements 5 | #------------------------------------------------------------------------------- 6 | # No requirements 7 | 8 | #------------------------------------------------------------------------------- 9 | # CMAKE OPTIONS 10 | #------------------------------------------------------------------------------- 11 | # No options 12 | 13 | #------------------------------------------------------------------------------- 14 | # CMAKE VARIABLES 15 | #------------------------------------------------------------------------------- 16 | # No variable 17 | 18 | #------------------------------------------------------------------------------- 19 | # CMAKE CONFIGURATIONS 20 | #------------------------------------------------------------------------------- 21 | configure_file(include/openstl/core/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/openstl/core/version.h) 22 | 23 | #------------------------------------------------------------------------------- 24 | # Build module 25 | #------------------------------------------------------------------------------- 26 | add_library(openstl_core INTERFACE) 27 | target_include_directories(openstl_core INTERFACE include/ ${CMAKE_CURRENT_BINARY_DIR}/generated/) 28 | add_library(openstl::core ALIAS openstl_core) -------------------------------------------------------------------------------- /tests/python/testutils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import numpy as np 3 | import pytest 4 | 5 | # Define Face and Vec3 as tuples 6 | Face = typing.Tuple[int, int, int] # v0, v1, v2 7 | Vec3 = typing.Tuple[float, float, float] 8 | 9 | @pytest.fixture 10 | def sample_triangles(): 11 | triangle = np.array([[0, 0, 1], [1, 1, 1], [2, 2, 2], [3, 3, 3]]) 12 | return np.stack([triangle]*1000) 13 | 14 | def are_all_unique(arr: list) -> bool: 15 | """Check if all elements in the array are unique.""" 16 | seen = set() 17 | for element in arr: 18 | if element in seen: 19 | return False 20 | seen.add(element) 21 | return True 22 | 23 | 24 | def are_faces_equal(face1: Face, face2: Face, v1: typing.List[Vec3], v2: typing.List[Vec3]) -> bool: 25 | """Check if two Face objects are equal.""" 26 | # Vertices v0, v1, v2 can be shuffled between two equal faces 27 | assert len(np.unique(face1)) == len(np.unique(face2)) 28 | for i in face1: 29 | if not any((v1[i] == v2[j]).all() for j in face2): 30 | return False 31 | return True 32 | 33 | 34 | def all_faces_valid(faces: typing.List[Face], final_faces: typing.List[Face], 35 | vertices: typing.List[Vec3], final_vertices: typing.List[Vec3]) -> bool: 36 | """Check if all original faces are present in the final faces.""" 37 | return all(any(are_faces_equal(face, final_f, vertices, final_vertices) for final_f in final_faces) for face in faces) -------------------------------------------------------------------------------- /tests/python/test_topology.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from openstl.topology import find_connected_components 4 | 5 | @pytest.fixture 6 | def sample_vertices_and_faces(): 7 | vertices = np.array([ 8 | [0.0, 0.0, 0.0], 9 | [1.0, 0.0, 0.0], 10 | [0.0, 1.0, 0.0], 11 | [1.0, 1.0, 0.0], 12 | [0.5, 0.5, 1.0], 13 | ]) 14 | faces = np.array([ 15 | [0, 1, 2], 16 | [1, 3, 2], 17 | [2, 3, 4], 18 | ]) 19 | return vertices, faces 20 | 21 | 22 | def test_single_connected_component(sample_vertices_and_faces): 23 | vertices, faces = sample_vertices_and_faces 24 | connected_components = find_connected_components(vertices, faces) 25 | 26 | # Expect one connected component containing all faces 27 | assert len(connected_components) == 1 28 | assert len(connected_components[0]) == 3 29 | 30 | 31 | def test_multiple_disconnected_components(sample_vertices_and_faces): 32 | vertices, faces = sample_vertices_and_faces 33 | # Add disconnected component 34 | faces = np.vstack([faces, [5, 6, 7]]) 35 | vertices = np.vstack([ 36 | vertices, 37 | [2.0, 2.0, 0.0], 38 | [3.0, 2.0, 0.0], 39 | [2.5, 3.0, 0.0] 40 | ]) 41 | 42 | connected_components = find_connected_components(vertices, faces) 43 | 44 | # Expect two connected components 45 | assert len(connected_components) == 2 46 | assert len(connected_components[0]) == 3 47 | assert len(connected_components[1]) == 1 48 | 49 | 50 | def test_no_faces(): 51 | vertices = np.array([ 52 | [0.0, 0.0, 0.0], 53 | [1.0, 1.0, 1.0], 54 | ]) 55 | faces = np.array([]).reshape(0, 3) 56 | 57 | connected_components = find_connected_components(vertices, faces) 58 | 59 | # Expect no connected components 60 | assert len(connected_components) == 0 61 | 62 | 63 | def test_single_face(): 64 | vertices = np.array([ 65 | [0.0, 0.0, 0.0], 66 | [1.0, 0.0, 0.0], 67 | [0.0, 1.0, 0.0], 68 | ]) 69 | faces = np.array([[0, 1, 2]]) 70 | 71 | connected_components = find_connected_components(vertices, faces) 72 | 73 | # Expect one connected component with one face 74 | assert len(connected_components) == 1 75 | assert len(connected_components[0]) == 1 76 | assert np.array_equal(connected_components[0][0], [0, 1, 2]) 77 | 78 | 79 | def test_disconnected_vertices(sample_vertices_and_faces): 80 | vertices, faces = sample_vertices_and_faces 81 | vertices = np.vstack([vertices, [10.0, 10.0, 10.0]]) # Add disconnected vertex 82 | 83 | connected_components = find_connected_components(vertices, faces) 84 | 85 | # Expect one connected component (disconnected vertex ignored) 86 | assert len(connected_components) == 1 87 | assert len(connected_components[0]) == 3 # Only faces contribute 88 | -------------------------------------------------------------------------------- /tests/python/test_convert.py: -------------------------------------------------------------------------------- 1 | import pytest, os 2 | import numpy as np 3 | import typing 4 | import openstl 5 | 6 | from .testutils import sample_triangles, are_faces_equal 7 | 8 | @pytest.fixture 9 | def sample_vertices_and_faces(): 10 | # Define vertices and faces 11 | vertices = np.array([ 12 | [0.0, 0.0, 0.0], 13 | [1.0, 1.0, 1.0], 14 | [2.0, 2.0, 2.0], 15 | [3.0, 3.0, 3.0], 16 | ]) 17 | faces = np.array([ 18 | [0, 1, 2], # Face 1 19 | [1, 3, 2] # Face 2 20 | ]) 21 | return vertices, faces 22 | 23 | 24 | def test_convert_to_vertices_and_faces_on_empty(): 25 | empty_triangles = np.array([[]]) 26 | vertices, faces = openstl.convert.verticesandfaces(empty_triangles) 27 | # Test if vertices and faces are empty 28 | assert len(vertices) == 0 29 | assert len(faces) == 0 30 | 31 | def test_convert_to_vertices_and_faces(sample_triangles): 32 | vertices, faces = openstl.convert.verticesandfaces(sample_triangles) 33 | # Convert vertices to tuples to make them hashable 34 | vertices = [tuple(vertex) for vertex in vertices] 35 | 36 | # Test if vertices and faces are extracted correctly 37 | assert len(vertices) == 3 38 | assert len(faces) == 1000 39 | 40 | # Test if each face contains three indices 41 | for face in faces: 42 | assert len(face) == 3 43 | 44 | # Test for uniqueness of vertices 45 | unique_vertices = set(vertices) 46 | assert len(unique_vertices) == len(vertices) 47 | 48 | # Test if all indices in faces are valid 49 | for face in faces: 50 | for vertex_idx in face: 51 | assert vertex_idx >= 0 52 | assert vertex_idx < len(vertices) 53 | 54 | 55 | def test_convertToVerticesAndFaces_integration(sample_vertices_and_faces): 56 | # Extract vertices and faces 57 | vertices, faces = sample_vertices_and_faces 58 | 59 | # Convert vertices and faces to triangles 60 | triangles = openstl.convert.triangles(vertices, faces) 61 | 62 | # Convert triangles back to vertices and faces 63 | result_vertices, result_faces = openstl.convert.verticesandfaces(triangles) 64 | 65 | # Check if the number of vertices and faces are preserved 66 | assert len(vertices) == len(result_vertices) 67 | assert len(faces) == len(result_faces) 68 | 69 | # Check if each vertices are preserved. 70 | found_set: list[int] = [] 71 | for i, result_vertex in enumerate(result_vertices): 72 | for ref_vertex in vertices: 73 | if (ref_vertex == result_vertex).all(): 74 | found_set.append(i) 75 | break 76 | assert len(found_set) == result_vertices.shape[0] 77 | 78 | # Check if each face is correctly preserved 79 | for face, result_face in zip(faces, result_faces): 80 | assert are_faces_equal(face, result_face, vertices, result_vertices) -------------------------------------------------------------------------------- /tests/core/src/serialize.test.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "openstl/tests/testutils.h" 3 | #include "openstl/core/stl.h" 4 | 5 | using namespace openstl; 6 | 7 | 8 | TEST_CASE("Serialize STL triangles", "[openstl]") { 9 | // Generate some sample triangles 10 | std::vector originalTriangles{ 11 | {{1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {1.0f, 1.0f, 0.0f}, 1u}, 12 | {{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 1.0f}, 2u} 13 | }; 14 | 15 | SECTION("Binary Format") { 16 | std::string filename{"test_binary.stl"}; 17 | std::ofstream stream(filename, std::ios::binary); 18 | if (!stream.is_open()) { 19 | std::cerr << "Error: Unable to open file " << filename << std::endl; 20 | return; 21 | } 22 | REQUIRE(stream.is_open()); 23 | 24 | // Serialize the triangles in binary format 25 | serialize(originalTriangles, stream, StlFormat::Binary); 26 | REQUIRE_FALSE(stream.fail()); 27 | stream.close(); 28 | 29 | // Deserialize the serialized triangles 30 | std::ifstream inFile(filename, std::ios::binary); 31 | REQUIRE(inFile.is_open()); 32 | auto deserializedTriangles = deserializeBinaryStl(inFile); 33 | 34 | // Validate deserialized triangles against original triangles 35 | REQUIRE(testutils::checkTrianglesEqual(deserializedTriangles, originalTriangles)); 36 | } 37 | 38 | SECTION("Binary Format stringstream") { 39 | std::stringstream ss; 40 | 41 | // Serialize the triangles in binary format 42 | serialize(originalTriangles, ss, StlFormat::Binary); 43 | 44 | // Deserialize the serialized triangles 45 | ss.seekg(0); 46 | auto deserializedTriangles = deserializeBinaryStl(ss); 47 | 48 | // Validate deserialized triangles against original triangles 49 | REQUIRE(testutils::checkTrianglesEqual(deserializedTriangles, originalTriangles)); 50 | } 51 | 52 | SECTION("ASCII Format") { 53 | std::string filename{"test_ascii.stl"}; 54 | std::ofstream stream(filename, std::ios::binary); 55 | if (!stream.is_open()) { 56 | std::cerr << "Error: Unable to open file " << filename << std::endl; 57 | return; 58 | } 59 | REQUIRE(stream.is_open()); 60 | 61 | // Serialize the triangles in ASCII format 62 | serialize(originalTriangles, stream, StlFormat::ASCII); 63 | REQUIRE_FALSE(stream.fail()); 64 | stream.close(); 65 | 66 | // Deserialize the serialized triangles 67 | std::ifstream inFile(filename); 68 | REQUIRE(inFile.is_open()); 69 | auto deserializedTriangles = deserializeAsciiStl(inFile); 70 | 71 | // Validate deserialized triangles against original triangles 72 | REQUIRE(testutils::checkTrianglesEqual(deserializedTriangles, originalTriangles, true)); 73 | } 74 | } -------------------------------------------------------------------------------- /.github/workflows/ci-and-bump.yml: -------------------------------------------------------------------------------- 1 | name: CI & Bump 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | # Default: no privileges; grant per-job 10 | permissions: {} 11 | 12 | # Avoid overlapping runs on the same ref (original push + bump commit) 13 | concurrency: 14 | group: ci-and-bump-${{ github.ref }} 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | build: 19 | if: github.event_name == 'pull_request' 20 | name: Build and Test on ${{ matrix.os }} 21 | runs-on: ${{ matrix.os }} 22 | permissions: 23 | contents: read 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest, windows-latest, macos-13, macos-14] 27 | steps: 28 | - name: Checkout (read-only) 29 | uses: actions/checkout@v4 30 | with: 31 | persist-credentials: false 32 | - name: Set up Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: '3.8' 36 | - name: Install package 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install . 40 | - name: Run tests 41 | run: | 42 | python -m pip install numpy pytest 43 | pytest -q 44 | 45 | bump_version_and_tag: 46 | # Run only on human pushes to main AND not for bump commits 47 | if: > 48 | github.event_name == 'push' && 49 | github.ref == 'refs/heads/main' && 50 | !startsWith(github.event.head_commit.message, 'bump:') && 51 | github.actor != 'github-actions[bot]' 52 | name: Bump version and tag on main 53 | runs-on: ubuntu-latest 54 | permissions: 55 | contents: write # needed to push commit + tag if fallback to GITHUB_TOKEN, but we use PAT 56 | steps: 57 | - name: Checkout (PAT; full history + tags) 58 | uses: actions/checkout@v4 59 | with: 60 | fetch-depth: 0 61 | fetch-tags: true 62 | persist-credentials: true 63 | token: ${{ secrets.GH_PAT }} # <— PAT so pushes trigger release workflow 64 | 65 | - name: Ensure tags are present & reachable 66 | id: tagcheck 67 | shell: bash 68 | run: | 69 | set -euo pipefail 70 | git fetch --tags --force --prune 71 | echo "Closest tag from HEAD (if any):" 72 | (git describe --tags --abbrev=0 --match 'v*' || echo "NONE") 73 | echo "Recent v* tags:" 74 | git tag --list 'v*' --sort=-creatordate | head -n 10 75 | 76 | - name: Set git identity 77 | run: | 78 | git config user.name "innoptech-bot" 79 | git config user.email "ruelj2@users.noreply.github.com" 80 | 81 | - name: Commitizen bump (no changelog, pushes commit+tag) 82 | id: cz 83 | uses: commitizen-tools/commitizen-action@0.24.0 84 | with: 85 | github_token: ${{ secrets.GH_PAT }} # <— PAT again 86 | push: true 87 | changelog: false 88 | 89 | - name: Show bumped version 90 | run: echo "Bumped to version ${{ steps.cz.outputs.version }}" 91 | -------------------------------------------------------------------------------- /tests/core/src/disjointsets.test.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "openstl/core/stl.h" 3 | 4 | using namespace openstl; 5 | 6 | TEST_CASE("DisjointSet basic operations", "[DisjointSet]") { 7 | DisjointSet ds(10); 8 | 9 | SECTION("Initial state") { 10 | for (size_t i = 0; i < 10; ++i) { 11 | REQUIRE(ds.find(i) == i); 12 | } 13 | } 14 | 15 | SECTION("Union operation") { 16 | ds.unite(0, 1); 17 | ds.unite(2, 3); 18 | ds.unite(1, 3); 19 | 20 | REQUIRE(ds.connected(0, 3)); 21 | REQUIRE(ds.connected(1, 2)); 22 | REQUIRE(!ds.connected(0, 4)); 23 | } 24 | 25 | SECTION("Find with path compression") { 26 | ds.unite(4, 5); 27 | ds.unite(5, 6); 28 | REQUIRE(ds.find(6) == ds.find(4)); 29 | REQUIRE(ds.find(5) == ds.find(4)); 30 | } 31 | 32 | SECTION("Disconnected sets") { 33 | ds.unite(7, 8); 34 | REQUIRE(!ds.connected(7, 9)); 35 | REQUIRE(ds.connected(7, 8)); 36 | } 37 | } 38 | 39 | TEST_CASE("Find connected components of faces", "[findConnectedComponents]") { 40 | std::vector> vertices = { 41 | {0.0f, 0.0f, 0.0f}, 42 | {1.0f, 0.0f, 0.0f}, 43 | {0.0f, 1.0f, 0.0f}, 44 | {1.0f, 1.0f, 0.0f}, 45 | {0.5f, 0.5f, 1.0f}, 46 | }; 47 | 48 | std::vector> faces = { 49 | {0, 1, 2}, 50 | {1, 3, 2}, 51 | {2, 3, 4}, 52 | }; 53 | 54 | SECTION("Single connected component") { 55 | auto connectedComponents = findConnectedComponents(vertices, faces); 56 | REQUIRE(connectedComponents.size() == 1); 57 | REQUIRE(connectedComponents[0].size() == 3); 58 | } 59 | 60 | SECTION("Multiple disconnected components") { 61 | faces.push_back({5, 6, 7}); 62 | vertices.push_back({2.0f, 2.0f, 0.0f}); 63 | vertices.push_back({3.0f, 2.0f, 0.0f}); 64 | vertices.push_back({2.5f, 3.0f, 0.0f}); 65 | 66 | auto connectedComponents = findConnectedComponents(vertices, faces); 67 | REQUIRE(connectedComponents.size() == 2); 68 | REQUIRE(connectedComponents[0].size() == 3); 69 | REQUIRE(connectedComponents[1].size() == 1); 70 | } 71 | 72 | SECTION("No faces provided") { 73 | faces.clear(); 74 | auto connectedComponents = findConnectedComponents(vertices, faces); 75 | REQUIRE(connectedComponents.empty()); 76 | } 77 | 78 | SECTION("Single face") { 79 | faces = {{0, 1, 2}}; 80 | auto connectedComponents = findConnectedComponents(vertices, faces); 81 | REQUIRE(connectedComponents.size() == 1); 82 | REQUIRE(connectedComponents[0].size() == 1); 83 | REQUIRE(connectedComponents[0][0] == std::array{0, 1, 2}); 84 | } 85 | 86 | SECTION("Disconnected vertices") { 87 | vertices.push_back({10.0f, 10.0f, 10.0f}); // Add an isolated vertex 88 | auto connectedComponents = findConnectedComponents(vertices, faces); 89 | REQUIRE(connectedComponents.size() == 1); 90 | REQUIRE(connectedComponents[0].size() == 3); // Only faces contribute 91 | } 92 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # triggered by PAT-backed push from CI & Bump 7 | 8 | jobs: 9 | build_wheels: 10 | name: Build wheels on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-13, macos-14] 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # cibuildwheel sometimes needs tags 19 | - uses: actions/setup-python@v5 20 | - name: Print the arch and system 21 | run: | 22 | python -c "import platform; print('System:', platform.system()); print('Architecture:', platform.machine())" 23 | - name: Install cibuildwheel 24 | run: python -m pip install --upgrade pip cibuildwheel==2.20.0 25 | - name: Build wheels 26 | env: 27 | CIBW_SKIP: "cp36-* cp37-* pp*" 28 | run: python -m cibuildwheel --output-dir wheelhouse 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} 32 | path: wheelhouse/*.whl 33 | 34 | publish-to-testpypi: 35 | name: Publish to TestPyPI 36 | needs: build_wheels 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: testpypi 40 | url: https://test.pypi.org/p/openstl 41 | permissions: 42 | id-token: write 43 | contents: read 44 | steps: 45 | - uses: actions/download-artifact@v4 46 | with: 47 | pattern: cibw-* 48 | path: dist 49 | merge-multiple: true 50 | - uses: pypa/gh-action-pypi-publish@release/v1 51 | with: 52 | repository-url: https://test.pypi.org/legacy/ 53 | 54 | publish-to-pypi: 55 | name: Publish to PyPI 56 | needs: publish-to-testpypi 57 | runs-on: ubuntu-latest 58 | environment: 59 | name: pypi 60 | url: https://pypi.org/p/openstl 61 | permissions: 62 | id-token: write 63 | contents: read 64 | steps: 65 | - uses: actions/download-artifact@v4 66 | with: 67 | pattern: cibw-* 68 | path: dist 69 | merge-multiple: true 70 | - uses: pypa/gh-action-pypi-publish@release/v1 71 | 72 | github-release: 73 | name: Sign & Upload to GitHub Release 74 | needs: publish-to-pypi 75 | runs-on: ubuntu-latest 76 | permissions: 77 | contents: write 78 | id-token: write 79 | steps: 80 | - uses: actions/download-artifact@v4 81 | with: 82 | pattern: cibw-* 83 | path: dist 84 | merge-multiple: true 85 | - name: Sign the dists with Sigstore 86 | uses: sigstore/gh-action-sigstore-python@v3.1.0 87 | with: 88 | inputs: ./dist/*.whl 89 | - name: Create GitHub Release 90 | env: 91 | GITHUB_TOKEN: ${{ github.token }} 92 | run: gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" 93 | - name: Upload artifacts 94 | env: 95 | GITHUB_TOKEN: ${{ github.token }} 96 | run: gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' 97 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15.0) 2 | 3 | 4 | #------------------------------------------------------------------------------- 5 | # Project Definitions 6 | #------------------------------------------------------------------------------- 7 | project(OPENSTL 8 | DESCRIPTION "A simple STL serializer and deserializer" 9 | LANGUAGES CXX) 10 | 11 | #------------------------------------------------------------------------------- 12 | # VERSIONING 13 | #------------------------------------------------------------------------------- 14 | add_subdirectory(cmake) 15 | list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") 16 | set(PYPROJECT_PATH "${CMAKE_CURRENT_BINARY_DIR}/pyproject.toml") 17 | configure_file("${PROJECT_SOURCE_DIR}/pyproject.toml" ${PYPROJECT_PATH}) 18 | ReadVersion(${PYPROJECT_PATH}) 19 | 20 | #------------------------------------------------------------------------------- 21 | # COMPILATION 22 | #------------------------------------------------------------------------------- 23 | set(CMAKE_CXX_STANDARD 17) 24 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 25 | set(CMAKE_CXX_EXTENSIONS OFF) 26 | 27 | # Compiler-specific flags 28 | if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") 29 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC -Wall -pthread") 30 | set(CMAKE_CXX_FLAGS_RELEASE "-O3 -fno-math-errno") 31 | set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g") 32 | elseif (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") 33 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc") 34 | set(CMAKE_CXX_FLAGS_RELEASE "/O2") 35 | set(CMAKE_CXX_FLAGS_DEBUG "/Od /Zi") 36 | endif() 37 | 38 | # Do not allow to build in main repo 39 | file(TO_CMAKE_PATH "${PROJECT_BINARY_DIR}/CMakeLists.txt" LOC_PATH) 40 | if(EXISTS "${LOC_PATH}") 41 | message(FATAL_ERROR "You cannot build in a source directory (or any directory with a CMakeLists.txt file). 42 | Please make a build subdirectory. Feel free to remove CMakeCache.txt and CMakeFiles.") 43 | endif() 44 | 45 | # Set the default build type 46 | set(default_build_type "Release") 47 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 48 | message(STATUS "Setting build type to '${default_build_type}' as none was specified.") 49 | set(CMAKE_BUILD_TYPE "${default_build_type}" CACHE 50 | STRING "Choose the type of build." FORCE) 51 | # Set the possible values of build type for cmake-gui 52 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 53 | "Debug" "Release") 54 | endif() 55 | 56 | #------------------------------------------------------------------------------- 57 | # CMAKE OPTIONS 58 | #------------------------------------------------------------------------------- 59 | option(OPENSTL_BUILD_TESTS "Enable the compilation of the test files." OFF) 60 | option(OPENSTL_BUILD_PYTHON "Enable the compilation of the python binding." OFF) 61 | 62 | if (CMAKE_BUILD_TYPE MATCHES Debug) 63 | add_definitions(-DDEBUG) 64 | endif() 65 | 66 | #------------------------------------------------------------------------------- 67 | # Add external components 68 | #------------------------------------------------------------------------------- 69 | # No external component at runtime 70 | 71 | #------------------------------------------------------------------------------- 72 | # Add module components 73 | #------------------------------------------------------------------------------- 74 | set(OPENSTL_MODULES core) 75 | foreach(module ${OPENSTL_MODULES}) 76 | add_subdirectory(modules/${module}) 77 | endforeach() 78 | 79 | #------------------------------------------------------------------------------- 80 | # Tests 81 | #------------------------------------------------------------------------------- 82 | if(OPENSTL_BUILD_TESTS) 83 | ReadDependencyVersion(catch2 ${PYPROJECT_PATH}) 84 | add_subdirectory(extern/catch2) 85 | # Configure automatic test registration 86 | list(APPEND CMAKE_MODULE_PATH ${Catch2_SOURCE_DIR}/extras) 87 | include(CTest) 88 | include(Catch) 89 | add_subdirectory(tests) 90 | endif() 91 | 92 | #------------------------------------------------------------------------------- 93 | # Python 94 | #------------------------------------------------------------------------------- 95 | if(OPENSTL_BUILD_PYTHON) 96 | ReadDependencyVersion(pybind11 ${PYPROJECT_PATH}) 97 | add_subdirectory(extern/pybind11) 98 | add_subdirectory(python) 99 | endif() 100 | 101 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os, re, sys 2 | import subprocess 3 | from pathlib import Path 4 | 5 | from setuptools import Extension, setup 6 | from setuptools.command.build_ext import build_ext 7 | 8 | 9 | def read_version_from_pyproject(file_path): 10 | with open(file_path, 'r') as file: 11 | content = file.read() 12 | 13 | pattern = r'\bversion\s*=\s*"([^"]+)"' 14 | match = re.search(pattern, content) 15 | 16 | if match: 17 | version = match.group(1) 18 | return version 19 | else: 20 | return None 21 | 22 | 23 | # Convert distutils Windows platform specifiers to CMake -A arguments 24 | PLAT_TO_CMAKE = { 25 | "win32": "Win32", 26 | "win-amd64": "x64", 27 | "win-arm32": "ARM", 28 | "win-arm64": "ARM64", 29 | } 30 | 31 | class CMakeExtension(Extension): 32 | def __init__(self, name: str, sourcedir: str = "", cmake: str = "cmake") -> None: 33 | super().__init__(name, sources=[]) 34 | self.sourcedir = os.fspath(Path(sourcedir).resolve()) 35 | self.cmake = cmake 36 | 37 | 38 | class CMakeBuild(build_ext): 39 | # Inspired from https://github.com/pybind/cmake_example/blob/master/setup.py 40 | def build_extension(self, ext: CMakeExtension) -> None: 41 | ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name) 42 | extdir = ext_fullpath.parent.resolve() 43 | 44 | debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug 45 | cfg = "Debug" if debug else "Release" 46 | 47 | # CMake lets you override the generator - we need to check this. 48 | # Can be set with Conda-Build, for example. 49 | cmake_generator = os.environ.get("CMAKE_GENERATOR", "") 50 | 51 | cmake_args = [ 52 | f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}", 53 | f"-DPYTHON_EXECUTABLE={sys.executable}", 54 | f"-DCMAKE_BUILD_TYPE={cfg}", 55 | '-DOPENSTL_BUILD_PYTHON:BOOL=ON' 56 | ] 57 | 58 | build_args = [] 59 | # Adding CMake arguments set as environment variable 60 | # (needed e.g. to build for ARM OSx on conda-forge) 61 | if "CMAKE_ARGS" in os.environ: 62 | cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item] 63 | 64 | if self.compiler.compiler_type != "msvc": 65 | # Using Ninja-build since it a) is available as a wheel and b) 66 | # multithreads automatically. MSVC would require all variables be 67 | # exported for Ninja to pick it up, which is a little tricky to do. 68 | # Users can override the generator with CMAKE_GENERATOR in CMake 69 | # 3.15+. 70 | if not cmake_generator or cmake_generator == "Ninja": 71 | try: 72 | import ninja 73 | 74 | ninja_executable_path = Path(ninja.BIN_DIR) / "ninja" 75 | cmake_args += [ 76 | "-GNinja", 77 | f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}", 78 | ] 79 | except ImportError: 80 | pass 81 | 82 | else: 83 | # Single config generators are handled "normally" 84 | single_config = any(x in cmake_generator for x in {"NMake", "Ninja"}) 85 | 86 | # CMake allows an arch-in-generator style for backward compatibility 87 | contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"}) 88 | 89 | # Specify the arch if using MSVC generator, but only if it doesn't 90 | # contain a backward-compatibility arch spec already in the 91 | # generator name. 92 | if not single_config and not contains_arch: 93 | cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]] 94 | 95 | # Multi-config generators have a different way to specify configs 96 | if not single_config: 97 | cmake_args += [ 98 | f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}" 99 | ] 100 | build_args += ["--config", cfg] 101 | 102 | if sys.platform.startswith("darwin"): 103 | # Cross-compile support for macOS - respect ARCHFLAGS if set 104 | archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", "")) 105 | if archs: 106 | cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))] 107 | 108 | # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level 109 | # across all generators. 110 | if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: 111 | # self.parallel is a Python 3 only way to set parallel jobs by hand 112 | # using -j in the build_ext call, not supported by pip or PyPA-build. 113 | if hasattr(self, "parallel") and self.parallel: 114 | # CMake 3.12+ only. 115 | build_args += [f"-j{self.parallel}"] 116 | 117 | build_temp = Path(self.build_temp) / ext.name 118 | if not build_temp.exists(): 119 | build_temp.mkdir(parents=True) 120 | 121 | subprocess.run([ext.cmake, ext.sourcedir] + cmake_args, cwd=build_temp, check=True) 122 | subprocess.run([ext.cmake, "--build", "."] + build_args, cwd=build_temp, check=True) 123 | 124 | test_deps = [ 125 | 'coverage', 126 | 'pytest', 127 | 'numpy' 128 | ] 129 | 130 | setup( 131 | name ="openstl", 132 | version =read_version_from_pyproject("pyproject.toml"), 133 | description ="A simple STL serializer and deserializer", 134 | long_description =open('README.md').read(), 135 | long_description_content_type='text/markdown', 136 | url ='https://github.com/Innoptech/OpenSTL', 137 | author ='Jean-Christophe Ruel', 138 | author_email ='info@innoptech.com', 139 | python_requires =">=3.4", 140 | ext_modules =[CMakeExtension("openstl", 141 | sourcedir=os.environ.get('OPENSTL_SOURCE_DIR', '.'), 142 | cmake=os.environ.get('OPENSTL_CMAKE_PATH', 'cmake'))], 143 | cmdclass ={'build_ext': CMakeBuild}, 144 | test_suite ="tests/python", 145 | tests_require =test_deps, 146 | extras_require ={'test': test_deps}, 147 | include_package_data =False, 148 | zip_safe =False, 149 | classifiers=[ 150 | "Development Status :: 5 - Production/Stable", 151 | "Intended Audience :: Developers", 152 | "Intended Audience :: Science/Research", 153 | "Topic :: Multimedia :: Graphics :: 3D Modeling", 154 | "Topic :: Scientific/Engineering :: Mathematics", 155 | "Topic :: Scientific/Engineering :: Visualization", 156 | "License :: OSI Approved :: BSD License", 157 | "Operating System :: OS Independent", 158 | "Operating System :: Microsoft :: Windows", 159 | "Operating System :: POSIX", 160 | "Operating System :: POSIX :: Linux", 161 | "Operating System :: MacOS", 162 | "Programming Language :: Python :: 3", 163 | "Programming Language :: Python :: 3.6", 164 | "Programming Language :: Python :: 3.7", 165 | "Programming Language :: Python :: 3.8", 166 | "Programming Language :: Python :: 3.9", 167 | "Programming Language :: Python :: 3.10", 168 | "Programming Language :: Python :: 3.11", 169 | "Programming Language :: Python :: 3.12" 170 | ], 171 | ) 172 | -------------------------------------------------------------------------------- /tests/utils/include/openstl/tests/testutils.h: -------------------------------------------------------------------------------- 1 | #ifndef OPENSTL_TESTS_TESTUTILS_H 2 | #define OPENSTL_TESTS_TESTUTILS_H 3 | #include "assets/assets_path.h" 4 | #include "openstl/core/stl.h" 5 | #include 6 | 7 | namespace openstl { 8 | namespace testutils { 9 | enum class TESTOBJECT { 10 | KEY, BALL, WASHER, COMPROMISED_TRIANGLE_COUNT, EMPTY_FILE 11 | }; 12 | 13 | inline std::string getTestObjectPath(TESTOBJECT obj) { 14 | std::string basename{}; 15 | switch (obj) { 16 | default: 17 | basename = "KEY.STL"; 18 | break; 19 | case TESTOBJECT::BALL: 20 | basename = "BALL.STL"; 21 | break; 22 | case TESTOBJECT::WASHER: 23 | basename = "WASHER.STL"; 24 | break; 25 | } 26 | return OPENSTL_TEST_ASSETSDIR + basename; 27 | } 28 | 29 | inline std::vector createTestTriangle() { 30 | Triangle triangle; 31 | triangle.normal = {0.1f, 0.2f, 1.0f}; 32 | triangle.v0 = {0.0f, 0.0f, 0.0f}; 33 | triangle.v1 = {1.0f, 0.0f, 0.0f}; 34 | triangle.v2 = {0.0f, 1.0f, 0.0f}; 35 | triangle.attribute_byte_count = 0; 36 | return {triangle}; 37 | } 38 | 39 | inline void createIncompleteTriangleData(const std::vector& triangles, const std::string& filename) { 40 | std::ofstream file(filename, std::ios::binary); 41 | 42 | // Write header (80 bytes for comments) 43 | char header[80] = "STL Exported by OpenSTL [https://github.com/Innoptech/OpenSTL]"; 44 | file.write(header, sizeof(header)); 45 | 46 | // Write triangle count (4 bytes) 47 | auto triangleCount = static_cast(triangles.size()); 48 | file.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 49 | 50 | // Write only half of the triangles to simulate incomplete data 51 | for (size_t i = 0; i < triangles.size() / 2; ++i) { 52 | file.write(reinterpret_cast(&triangles[i]), sizeof(Triangle)); 53 | } 54 | file.close(); 55 | } 56 | 57 | inline void createCorruptedHeaderTruncated(const std::vector& triangles, const std::string& filename) { 58 | std::ofstream file(filename, std::ios::binary); 59 | 60 | // Truncated header (less than 80 bytes) 61 | char header[40] = "TruncatedHeader"; 62 | file.write(header, sizeof(header)); // Writing only 40 bytes instead of 80 63 | 64 | // Write triangle count and triangles normally 65 | auto triangleCount = static_cast(triangles.size()); 66 | file.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 67 | for (const auto& tri : triangles) { 68 | file.write(reinterpret_cast(&tri), sizeof(Triangle)); 69 | } 70 | 71 | file.close(); 72 | } 73 | 74 | inline void createCorruptedHeaderExcessData(const std::vector& triangles, const std::string& filename) { 75 | std::ofstream file(filename, std::ios::binary); 76 | 77 | // Correct header followed by garbage data 78 | char header[80] = "STL Exported by OpenSTL [https://github.com/Innoptech/OpenSTL]"; 79 | file.write(header, sizeof(header)); 80 | 81 | // Write some garbage data to corrupt the file 82 | char garbage[20] = "GARBAGE DATA"; 83 | file.write(garbage, sizeof(garbage)); 84 | 85 | // Write triangle count and triangles normally 86 | auto triangleCount = static_cast(triangles.size()); 87 | file.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 88 | for (const auto& tri : triangles) { 89 | file.write(reinterpret_cast(&tri), sizeof(Triangle)); 90 | } 91 | 92 | file.close(); 93 | } 94 | 95 | inline void createExcessiveTriangleCount(const std::vector& triangles, const std::string& filename) { 96 | std::ofstream file(filename, std::ios::binary); 97 | 98 | // Write header (80 bytes for comments) 99 | char header[80] = "STL Exported by OpenSTL [https://github.com/Innoptech/OpenSTL]"; 100 | file.write(header, sizeof(header)); 101 | 102 | // Write an excessive triangle count (much larger than actual size) 103 | uint32_t excessiveCount = std::numeric_limits::max(); // Adding 1000 to the actual count 104 | file.write(reinterpret_cast(&excessiveCount), sizeof(excessiveCount)); 105 | file.close(); 106 | } 107 | 108 | inline void createCorruptedHeaderInvalidChars(const std::vector& triangles, const std::string& filename) { 109 | std::ofstream file(filename, std::ios::binary); 110 | 111 | // Corrupted header with random invalid characters 112 | char header[80] = "CorruptedHeader12345!@#$%&*()"; 113 | file.write(header, sizeof(header)); 114 | 115 | // Write triangle count and triangles normally 116 | auto triangleCount = static_cast(triangles.size()); 117 | file.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 118 | for (const auto& tri : triangles) { 119 | file.write(reinterpret_cast(&tri), sizeof(Triangle)); 120 | } 121 | 122 | file.close(); 123 | } 124 | 125 | inline void createBufferOverflowOnTriangleCount(const std::string& filename) { 126 | std::ofstream file(filename, std::ios::binary); 127 | 128 | // Write only the header (80 bytes) and close the file 129 | char header[80] = "STL Exported by OpenSTL [https://github.com/Innoptech/OpenSTL]"; 130 | file.write(header, sizeof(header)); 131 | file.close(); 132 | } 133 | 134 | inline void createStlWithTriangles(const std::vector& triangles, const std::string& filename) { 135 | std::ofstream file(filename, std::ios::binary); 136 | 137 | // Write a valid header 138 | char header[80] = "STL Exported by Test"; 139 | file.write(header, sizeof(header)); 140 | 141 | // Write the number of triangles 142 | uint32_t triangleCount = static_cast(triangles.size()); 143 | file.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 144 | 145 | // Write the triangles 146 | file.write(reinterpret_cast(triangles.data()), triangles.size() * sizeof(Triangle)); 147 | 148 | file.close(); 149 | } 150 | 151 | 152 | inline void createEmptyStlFile(const std::string& filename) { 153 | std::ofstream file(filename, std::ios::binary); 154 | // Simply create the file and close it without writing anything to it 155 | file.close(); 156 | } 157 | 158 | // Custom equality operator for Vertex struct 159 | inline bool operator!=(const Vec3& rhs, const Vec3& lhs) { 160 | return std::tie(rhs.x, rhs.y, rhs.z) != std::tie(lhs.x, lhs.y, lhs.z); 161 | } 162 | 163 | // Utility function to compare two vectors of triangles 164 | inline bool checkTrianglesEqual(const std::vector& a, const std::vector& b, bool omit_attribute=false) { 165 | if (a.size() != b.size()) return false; 166 | for (size_t i = 0; i < a.size(); ++i) { 167 | if (a[i].normal != b[i].normal || 168 | a[i].v0 != b[i].v0 || 169 | a[i].v1 != b[i].v1 || 170 | a[i].v2 != b[i].v2 || 171 | ((a[i].attribute_byte_count != b[i].attribute_byte_count) & !omit_attribute)) { 172 | return false; 173 | } 174 | } 175 | return true; 176 | } 177 | 178 | 179 | } //namespace testutils 180 | } //namespace openstl 181 | #endif //OPENSTL_TESTS_TESTUTILS_H 182 | -------------------------------------------------------------------------------- /benchmark/benchmark.py: -------------------------------------------------------------------------------- 1 | import openstl 2 | import torch 3 | import numpy as np 4 | import meshio 5 | import timeit 6 | from stl import mesh, Mode 7 | import stl_reader 8 | import matplotlib.pyplot as plt 9 | 10 | #----------------------------------------------------- 11 | # openstl 12 | #----------------------------------------------------- 13 | def create_triangles(num_triangles): 14 | triangles = [] 15 | tri = np.array([[0, 0, 1], [1, 1, 1], [2, 2, 2], [3, 3, 3]]) 16 | return np.tile(tri[np.newaxis,:,:], (num_triangles,1,1)) 17 | 18 | def benchmark_write_openstl(num_triangles, filename): 19 | triangles = create_triangles(num_triangles) 20 | result = timeit.timeit(lambda: openstl.write(filename, triangles, openstl.format.binary), number=5) 21 | return result 22 | 23 | def benchmark_read_openstl(filename): 24 | result = timeit.timeit(lambda: openstl.read(filename), number=5) 25 | return result 26 | 27 | def benchmark_rotate_openstl(num_triangles): 28 | triangles = create_triangles(num_triangles) 29 | cos, sin = np.cos(np.pi/3), np.sin(np.pi/3) 30 | matrix = np.array([[cos, -sin, 0],[sin, cos, 0], [0,0,1]]) # rotation of pi/3 around z axis 31 | result = timeit.timeit(lambda: np.matmul(matrix, triangles.reshape(-1,3).T), number=5) 32 | return result 33 | 34 | def benchmark_rotate_openstl_torch(num_triangles): 35 | triangles = torch.Tensor(create_triangles(num_triangles)).to('cuda') 36 | cos, sin = np.cos(np.pi/3), np.sin(np.pi/3) 37 | matrix = torch.Tensor([[cos, -sin, 0],[sin, cos, 0], [0,0,1]]).to('cuda') # rotation of pi/3 around z axis 38 | result = timeit.timeit(lambda: torch.matmul(matrix, triangles.reshape(-1,3).T), number=5) 39 | return result 40 | 41 | #----------------------------------------------------- 42 | # nympy-stl 43 | #----------------------------------------------------- 44 | def create_nympystl(num_triangles): 45 | triangles = np.zeros(num_triangles, dtype=mesh.Mesh.dtype) 46 | mesh_data = mesh.Mesh(triangles) 47 | return mesh_data 48 | 49 | def benchmark_read_numpy_stl(filename): 50 | result = timeit.timeit(lambda: mesh.Mesh.from_file(filename, mode=Mode.BINARY), number=5) 51 | return result 52 | 53 | def benchmark_write_numpy_stl(num_triangles, filename): 54 | mesh_data = create_nympystl(num_triangles) 55 | result = timeit.timeit(lambda: mesh_data.save(filename, mode=Mode.BINARY), number=5) 56 | return result 57 | 58 | def benchmark_rotate_openstl_numpy(num_triangles): 59 | mesh_data = create_nympystl(num_triangles) 60 | result = timeit.timeit(lambda: mesh_data.rotate([0.0, 0.0, 1.0], np.pi/3), number=5) 61 | return result 62 | 63 | #----------------------------------------------------- 64 | # meshio 65 | #----------------------------------------------------- 66 | def create_meshio_triangles(num_triangles): 67 | points = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3]]) 68 | cells = [("triangle", np.tile([0,1,2], (num_triangles, 1)))] 69 | return meshio.Mesh(points, cells) 70 | 71 | def benchmark_write_meshio(num_triangles, filename): 72 | mesh = create_meshio_triangles(num_triangles) 73 | result = timeit.timeit(lambda: meshio.write(filename, mesh, "stl"), number=5) 74 | return result 75 | 76 | def benchmark_read_meshio(filename): 77 | result = timeit.timeit(lambda: meshio.read(filename), number=5) 78 | return result 79 | 80 | #----------------------------------------------------- 81 | # stl-reader 82 | #----------------------------------------------------- 83 | def benchmark_read_stl_reader(filename): 84 | result = timeit.timeit(lambda: stl_reader.read(filename), number=5) 85 | return result 86 | 87 | #----------------------------------------------------- 88 | # benchmark utils 89 | #----------------------------------------------------- 90 | def benchmark_library(num_triangles_list, write_func, read_func, rotate_func=None, library_name="Library"): 91 | write_times = [] 92 | read_times = [] 93 | rotate_times = [] if rotate_func else None 94 | 95 | # Warm-up to exclude initialization overhead 96 | write_func(1000, "warmup.stl") 97 | read_func("warmup.stl") 98 | 99 | for num_triangles in num_triangles_list: 100 | write_times.append(write_func(num_triangles, f"benchmark_{num_triangles}.stl")) 101 | 102 | for num_triangles in num_triangles_list: 103 | read_times.append(read_func(f"benchmark_{num_triangles}.stl")) 104 | 105 | if rotate_func: 106 | for num_triangles in num_triangles_list: 107 | rotate_times.append(rotate_func(num_triangles)) 108 | 109 | return write_times, read_times, rotate_times 110 | 111 | def calculate_speedup(openstl_times, other_times): 112 | speedup = np.array(other_times) / np.array(openstl_times) 113 | return speedup.round(3) 114 | 115 | def display_speedup_results(name, write_speedup, read_speedup, rotate_speedup=None): 116 | print(f"Write:\tOpenSTL is {write_speedup.min()} to {write_speedup.max()} X faster than {name}") 117 | print(f"Read:\tOpenSTL is {read_speedup.min()} to {read_speedup.max()} X faster than {name}") 118 | if rotate_speedup is not None: 119 | print(f"Rotate:\tOpenSTL is {rotate_speedup.min()} to {rotate_speedup.max()} X faster than {name}") 120 | 121 | def plot_benchmark_results(num_triangles_list, results, labels, filename="benchmark.png"): 122 | plt.figure(figsize=(10, 6)) 123 | 124 | for result, label, style in results: 125 | plt.plot(num_triangles_list, result, label=label, **style) 126 | 127 | plt.xlabel("Number of Triangles", fontsize=12) 128 | plt.ylabel("Time (seconds)", fontsize=12) 129 | plt.title("Python Benchmark Results", fontsize=14) 130 | plt.xscale("log") 131 | plt.yscale("log") 132 | plt.legend(fontsize=10, handlelength=5, loc='upper left', ncol=3) 133 | plt.grid(True, which="both", linestyle="--", linewidth=0.5) 134 | plt.tight_layout() 135 | plt.xlim(num_triangles_list[0], num_triangles_list[-1] * 1.1) 136 | plt.savefig(filename) 137 | 138 | if __name__ == "__main__": 139 | filename = "benchmark.stl" 140 | num_triangles_list = np.logspace(1, 5, 15).round().astype(int) 141 | 142 | # OpenSTL Benchmark 143 | openstl_write, openstl_read, openstl_rotate = benchmark_library(num_triangles_list, benchmark_write_openstl, benchmark_read_openstl, benchmark_rotate_openstl, "OpenSTL") 144 | 145 | # OpenSTL + PyTorch Benchmark (if available) 146 | if torch.cuda.is_available(): 147 | _, _, openstl_rotate_torch = benchmark_library(num_triangles_list, lambda *args: None, lambda *args: None, benchmark_rotate_openstl_torch, "OpenSTL + PyTorch") 148 | 149 | # Numpy STL Benchmark 150 | numpy_write, numpy_read, numpy_rotate = benchmark_library(num_triangles_list, benchmark_write_numpy_stl, benchmark_read_numpy_stl, benchmark_rotate_openstl_numpy, "Numpy STL") 151 | 152 | # Meshio Benchmark 153 | meshio_write, meshio_read, _ = benchmark_library(num_triangles_list, benchmark_write_meshio, benchmark_read_meshio, library_name="Meshio") 154 | 155 | # STL Reader Benchmark (Read-only) 156 | _, stl_reader_read, _ = benchmark_library(num_triangles_list, benchmark_write_openstl, benchmark_read_stl_reader, library_name="STL Reader") 157 | 158 | # Calculate and display speedup 159 | write_speedup_np = calculate_speedup(openstl_write, numpy_write) 160 | read_speedup_np = calculate_speedup(openstl_read, numpy_read) 161 | rotate_speedup_np = calculate_speedup(openstl_rotate, numpy_rotate) 162 | 163 | display_speedup_results("numpy-stl", write_speedup_np, read_speedup_np, rotate_speedup_np) 164 | 165 | if torch.cuda.is_available(): 166 | rotate_speedup_torch = calculate_speedup(openstl_rotate_torch, numpy_rotate) 167 | print(f"Rotate:\tOpenSTL + PyTorch is {rotate_speedup_torch.min()} to {rotate_speedup_torch.max()} X faster than numpy-stl") 168 | 169 | write_speedup_meshio = calculate_speedup(openstl_write, meshio_write) 170 | read_speedup_meshio = calculate_speedup(openstl_read, meshio_read) 171 | 172 | display_speedup_results("meshio", write_speedup_meshio, read_speedup_meshio) 173 | 174 | read_speedup_stl_reader = calculate_speedup(openstl_read, stl_reader_read) 175 | print(f"Read:\tOpenSTL is {read_speedup_stl_reader.min()} to {read_speedup_stl_reader.max()} X faster than stl_reader") 176 | 177 | # Plot results 178 | styles = [ 179 | (openstl_write, "Write (OpenSTL)", {"color": "green", "linestyle": "-", "marker": "s", "markersize": 7, "linewidth": 3}), 180 | (openstl_read, "Read (OpenSTL)", {"color": "blue", "linestyle": "-", "marker": "s", "markersize": 7, "linewidth": 3}), 181 | (openstl_rotate, "Rotate (OpenSTL)", {"color": "red", "linestyle": "-", "marker": "s", "markersize": 7, "linewidth": 3}) 182 | ] 183 | 184 | if torch.cuda.is_available(): 185 | styles.append((openstl_rotate_torch, "Rotate (OpenSTL + PyTorch)", {"color": "purple", "linestyle": "-", "marker": "s", "markersize": 7, "linewidth": 3})) 186 | 187 | styles += [ 188 | (numpy_write, "Write (numpy-stl)", {"color": "green", "linestyle": "--", "marker": "o", "markersize": 5, "alpha": 0.5}), 189 | (numpy_read, "Read (numpy-stl)", {"color": "blue", "linestyle": "--", "marker": "o", "markersize": 5, "alpha": 0.5}), 190 | (numpy_rotate, "Rotate (numpy-stl)", {"color": "red", "linestyle": "--", "marker": "o", "markersize": 5, "alpha": 0.5}), 191 | (meshio_write, "Write (meshio)", {"color": "green", "linestyle": ":", "marker": "^", "markersize": 5, "alpha": 0.5}), 192 | (meshio_read, "Read (meshio)", {"color": "blue", "linestyle": ":", "marker": "^", "markersize": 5, "alpha": 0.5}), 193 | (stl_reader_read, "Read (stl_reader)", {"color": "blue", "linestyle": "-.", "marker": "x", "markersize": 5, "alpha": 0.5}), 194 | ] 195 | 196 | plot_benchmark_results(num_triangles_list, styles, "benchmark.png") -------------------------------------------------------------------------------- /tests/core/src/convert.test.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "openstl/core/stl.h" 3 | #include 4 | #include 5 | 6 | using namespace openstl; 7 | 8 | 9 | 10 | TEST_CASE("findInverseMap function test", "[openstl::core]") { 11 | SECTION("Empty input vector") { 12 | std::vector triangles; 13 | auto inverseMap = findInverseMap(triangles); 14 | REQUIRE(inverseMap.empty()); 15 | } 16 | 17 | SECTION("Input vector with one triangle") { 18 | std::vector triangles = { 19 | Triangle{{1.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0} 20 | }; 21 | auto inverseMap = findInverseMap(triangles); 22 | REQUIRE(inverseMap.size() == 3); 23 | REQUIRE(inverseMap[triangles[0].v0].size() == 1); 24 | REQUIRE(inverseMap[triangles[0].v1].size() == 1); 25 | REQUIRE(inverseMap[triangles[0].v2].size() == 1); 26 | } 27 | 28 | SECTION("Input vector with multiple triangles") { 29 | std::vector triangles = { 30 | Triangle{{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0}, 31 | Triangle{{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0}, 32 | Triangle{{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0} 33 | }; 34 | auto inverseMap = findInverseMap(triangles); 35 | REQUIRE(inverseMap.size() == 3); 36 | REQUIRE(inverseMap[triangles[0].v0].size() == 3); // All vertices are similar 37 | REQUIRE(inverseMap[triangles[0].v1].size() == 3); 38 | REQUIRE(inverseMap[triangles[0].v2].size() == 3); 39 | } 40 | SECTION("Multiples vertices and different triangles") 41 | { 42 | const Vec3 v0{1.0f, 2.0f, 3.0f}, v1{4.0f, 5.0f, 6.0f}, v2{7.0f, 8.0f, 9.0f}, v3{10.0f, 20.0f, 30.0f}; 43 | const Vec3 normal{0.f, 0.f, 1.f}; 44 | std::vector triangles = { 45 | {normal, v0, v1, v2, 0}, 46 | {normal, v2, v1, v0, 0}, // Duplicate vertices 47 | {normal, v0, v1, v3, 0}, 48 | }; 49 | const auto& inverseMap = findInverseMap(triangles); 50 | REQUIRE(inverseMap.size() == 4); 51 | 52 | // Check if specific vertices are present in the set 53 | REQUIRE(inverseMap.count(v0) == 1); 54 | REQUIRE(inverseMap.count(v1) == 1); 55 | REQUIRE(inverseMap.count(v2) == 1); 56 | REQUIRE(inverseMap.count(v3) == 1); 57 | REQUIRE(inverseMap.count(normal) == 0); // Not a vertex, a normal 58 | 59 | // Check the number of face indices per vertex 60 | REQUIRE(inverseMap.at(v0).size() == 3); 61 | REQUIRE(inverseMap.at(v1).size() == 3); 62 | REQUIRE(inverseMap.at(v2).size() == 2); 63 | REQUIRE(inverseMap.at(v3).size() == 1); 64 | } 65 | } 66 | 67 | TEST_CASE("convertToVerticesAndFaces function test", "[convertToVerticesAndFaces]") { 68 | SECTION("Empty input vector") { 69 | std::vector triangles; 70 | auto result = convertToVerticesAndFaces(triangles); 71 | REQUIRE(std::get<0>(result).empty()); 72 | REQUIRE(std::get<1>(result).empty()); 73 | } 74 | 75 | SECTION("Input vector with one triangle") { 76 | std::vector triangles = { 77 | Triangle{{1.0f, 2.0f, 3.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0} 78 | }; 79 | auto result = convertToVerticesAndFaces(triangles); 80 | REQUIRE(std::get<0>(result).size() == 3); 81 | REQUIRE(std::get<1>(result).size() == 1); 82 | } 83 | SECTION("Input vector with multiple triangles") { 84 | std::vector triangles = { 85 | Triangle{{0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0}, 86 | Triangle{{0.0f, 0.0f, 2.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 1.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, 0}, 87 | Triangle{{0.0f, 0.0f, 3.0f}, {1.0f, 1.0f, 0.0f}, {2.0f, 1.0f, 0.0f}, {1.0f, 2.0f, 0.0f}, 0} 88 | }; 89 | auto result = convertToVerticesAndFaces(triangles); 90 | const auto& vertices = std::get<0>(result); 91 | const auto& faces = std::get<1>(result); 92 | 93 | REQUIRE(vertices.size() == 6); // There are 9 unique vertices in the triangles 94 | REQUIRE(faces.size() == 3); // There are 3 triangles 95 | 96 | // Check if each face has three vertices 97 | for (const auto& face : faces) { 98 | REQUIRE(face.size() == 3); // v0, v1, v2 99 | // Check if all indices in each face are unique 100 | std::unordered_set uniqueIndices(std::begin(face), std::end(face)); 101 | REQUIRE(uniqueIndices.size() == face.size()); 102 | } 103 | 104 | // Check the correctness of vertices and faces 105 | // Here we are just checking if the vertices and faces are correctly extracted 106 | std::unordered_set uniqueVertices(std::begin(vertices), std::end(vertices)); 107 | REQUIRE(uniqueVertices.size() == vertices.size()); // Check for uniqueness of vertices 108 | 109 | // Check if each face contains valid indices to the vertices 110 | for (const auto& face : faces) { 111 | for (size_t vertexIdx : face) { 112 | REQUIRE(vertexIdx >= 0); 113 | REQUIRE(vertexIdx < vertices.size()); 114 | } 115 | } 116 | } 117 | } 118 | 119 | TEST_CASE("convertToTriangles function test", "[convertToTriangles]") { 120 | SECTION("Face index out of range") { 121 | std::vector vertices = { 122 | {0.0f, 0.0f, 1.0f} 123 | }; 124 | std::vector faces = { 125 | {0, 1, 2} 126 | }; 127 | REQUIRE_THROWS_AS(convertToTriangles(vertices, faces), std::out_of_range); 128 | } 129 | SECTION("Valid input") { 130 | Vec3 v0{0.0f, 0.0f, 0.0f}; 131 | Vec3 v1{1.0f, 0.0f, 0.0f}; 132 | Vec3 v2{0.0f, 1.0f, 0.0f}; 133 | std::vector vertices = { 134 | v0,v1,v2, 135 | {0.0f, 5.0f, 0.0f} // extra vertex, not indexed 136 | }; 137 | 138 | std::vector faces = { 139 | {0, 1, 2} // v0,v1,v2 140 | }; 141 | 142 | auto triangles = convertToTriangles(vertices, faces); 143 | 144 | REQUIRE(triangles.size() == 1); 145 | 146 | const auto& triangle = triangles[0]; 147 | REQUIRE(triangle.v0 == v0); 148 | REQUIRE(triangle.v1 == v1); 149 | REQUIRE(triangle.v2 == v2); 150 | } 151 | } 152 | 153 | template 154 | bool areAllUnique(const std::array& arr) { 155 | std::unordered_set seen; 156 | for (const auto& element : arr) { 157 | if (!seen.insert(element).second) { 158 | // If insertion fails, the element is not unique 159 | return false; 160 | } 161 | } 162 | // If the loop completes, all elements are unique 163 | return true; 164 | } 165 | 166 | // Helper function to check if two Vec3 objects are equal 167 | bool areVec3Equal(const Vec3& v1, const Vec3& v2) { 168 | return v1.x == v2.x && v1.y == v2.y && v1.z == v2.z; 169 | } 170 | 171 | // Helper function to check if two Face objects are equal. 172 | // Vertices v0, v1, v2 can be shuffled between two equal faces 173 | bool areFacesEqual(const Face& f1, const Face& f2, const std::vector &v1, const std::vector &v2) { 174 | assert(areAllUnique(f1) && areAllUnique(f2)); 175 | return std::all_of( 176 | std::begin(f1),std::end(f1), 177 | [&](const size_t &idx_f1) { 178 | return std::any_of( 179 | std::begin(f2), std::end(f2), 180 | [&](const size_t &idx_f2) { return areVec3Equal(v1[idx_f1], v2[idx_f2]); } 181 | ); 182 | } 183 | ); 184 | } 185 | 186 | TEST_CASE("convertToVerticesAndFaces <-> convertToTriangles integration test", "[integration]") { 187 | std::vector vertices = { 188 | {0.0f, 0.0f, 0.0f}, 189 | {1.0f, 0.0f, 0.0f}, 190 | {0.0f, 1.0f, 0.0f}, 191 | {1.0f, 1.0f, 0.0f}, 192 | {0.5f, 0.5f, 1.0f}, 193 | }; 194 | 195 | // Convert vertices to faces 196 | std::vector faces = { 197 | {0, 1, 2}, // indices for: v0, v1, v2 198 | {1, 3, 2}, 199 | {2, 3, 4}, 200 | }; 201 | 202 | // Convert faces to triangles 203 | auto triangles = convertToTriangles(vertices, faces); 204 | 205 | // Convert triangles back to vertices and faces 206 | auto result = convertToVerticesAndFaces(triangles); 207 | const auto& finalVertices = std::get<0>(result); 208 | const auto& finalFaces = std::get<1>(result); 209 | 210 | // Check if all original vertices are present in the final vertices 211 | bool allVerticesFound = std::all_of( 212 | std::begin(vertices),std::end(vertices), 213 | [&finalVertices](const Vec3 &vertex) { 214 | return std::any_of( 215 | std::begin(finalVertices), std::end(finalVertices), 216 | [&vertex](const Vec3 &final_v) { return areVec3Equal(vertex, final_v); } 217 | ); 218 | } 219 | ); 220 | REQUIRE(allVerticesFound); 221 | 222 | 223 | // Check if all original faces are present in the final faces 224 | bool allFacesValid = std::all_of( 225 | std::begin(faces),std::end(faces), 226 | [&](const Face &face) { 227 | return std::any_of( 228 | std::begin(finalFaces), std::end(finalFaces), 229 | [&](const Face &final_f) { return areFacesEqual(face, final_f, vertices, finalVertices); } 230 | ); 231 | } 232 | ); 233 | REQUIRE(allFacesValid); 234 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenSTL 2 | The fastest and most intuitive library to manipulate STL files (stereolithography) for C++ and Python, header-only. 3 | `pip install openstl` 4 | 5 | 6 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=flat-square)](http://commitizen.github.io/cz-cli/) 7 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) 8 | [![PyPI license](https://img.shields.io/pypi/l/ansicolortags.svg?style=flat-square)](LICENSE) 9 | [![pypi](https://badge.fury.io/py/openstl.svg?style=flat-square)](https://badge.fury.io/py/openstl) 10 | [![Build & Publish](https://github.com/Innoptech/OpenSTL/actions/workflows/release.yml/badge.svg?style=flat-square)](https://github.com/Innoptech/OpenSTL/actions/workflows/release.yml) 11 | [![Python](https://img.shields.io/pypi/pyversions/openstl.svg)](https://pypi.org/project/openstl/) 12 | 13 | 14 | # Performances benchmark 15 | Discover the staggering performance of OpenSTL in comparison to [numpy-stl](https://github.com/wolph/numpy-stl), 16 | [meshio](https://github.com/nschloe/meshio) and [stl-reader](https://github.com/pyvista/stl-reader), thanks to its powerful C++ backend. 17 | See [benchmark.py](benchmark/benchmark.py). Benchmark performed on an Intel i5-9600KF CPU @ 3.70GHz. 18 | 19 | ![Benchmark Results](benchmark/benchmark.png) 20 | 21 | Performance gains over numpy-stl, meshio and stl-reader 22 | #openstl vs numpy-stl 23 | Write: OpenSTL is 1.262 to 5.998 X faster than numpy-stl 24 | Read: OpenSTL is 2.131 to 11.144 X faster than numpy-stl 25 | Rotate: OpenSTL is 0.971 to 13.873 X faster than numpy-stl 26 | Rotate: OpenSTL + PyTorch is 0.022 to 100.25 X faster than numpy-stl 27 | 28 | #openstl vs meshio 29 | Write: OpenSTL is 4.289 to 80.714 X faster than meshio 30 | Read: OpenSTL is 15.915 to 311.365 X faster than meshio 31 | 32 | #openstl vs stl_reader 33 | Read: OpenSTL is 0.719 to 2.2 X faster than stl_reader 34 | 35 | Note: meshio has no specific way of rotating vertices, so it was not benchmarked. 36 | 37 | # Python Usage 38 | ### Install 39 | `pip install openstl` or `pip install -U git+https://github.com/Innoptech/OpenSTL@main` 40 | 41 | ### Read and write from a STL file 42 | ```python 43 | import openstl 44 | import numpy as np 45 | 46 | # Define an array of triangles 47 | # Following the STL standard, each triangle is defined with : normal, v0, v1, v2 48 | quad = np.array([ 49 | # normal, vertices 0, vertices 1, vertices 2 50 | [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], # Triangle 1 51 | [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]], # Triangle 2 52 | ]) 53 | 54 | # Serialize the triangles to a file 55 | success = openstl.write("quad.stl", quad, openstl.format.binary) # Or openstl.format.ascii (slower but human readable) 56 | 57 | if not success: 58 | raise Exception("Error: Failed to write to the specified file.") 59 | 60 | # Deserialize triangles from a file 61 | deserialized_quad = openstl.read("quad.stl") 62 | 63 | # Print the deserialized triangles 64 | print("Deserialized Triangles:", deserialized_quad) 65 | ``` 66 | ### Rotate, translate and scale a mesh 67 | ```python 68 | import openstl 69 | import numpy as np 70 | 71 | quad = openstl.read("quad.stl") 72 | 73 | # Rotating 74 | rotation_matrix = np.array([ 75 | [0,-1, 0], 76 | [1, 0, 0], 77 | [0, 0, 1] 78 | ]) 79 | rotated_quad = np.matmul(rotation_matrix, quad.reshape(-1,3).T).T.reshape(-1,4,3) 80 | 81 | # Translating 82 | translation_vector = np.array([1,1,1]) 83 | quad[:,1:4,:] += translation_vector # Avoid translating normals 84 | 85 | # Scaling 86 | scale = 1000.0 87 | quad[:,1:4,:] *= scale # Avoid scaling normals 88 | ``` 89 | 90 | ### Convert Triangles :arrow_right: Vertices and Faces 91 | ```python 92 | import openstl 93 | 94 | # Define an array of triangles 95 | triangles = [ 96 | # normal, vertices 0, vertices 1, vertices 2 97 | [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], # Triangle 1 98 | [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]], # Triangle 2 99 | ] 100 | 101 | # Convert triangles to vertices and faces 102 | vertices, faces = openstl.convert.verticesandfaces(triangles) 103 | ``` 104 | 105 | ### Convert Vertices and Faces :arrow_right: Triangles 106 | ```python 107 | import openstl 108 | 109 | # Define vertices and faces 110 | vertices = [ 111 | [0.0, 0.0, 0.0], 112 | [1.0, 1.0, 1.0], 113 | [2.0, 2.0, 2.0], 114 | [3.0, 3.0, 3.0], 115 | ] 116 | 117 | faces = [ 118 | [0, 1, 2], # Face 1 119 | [1, 3, 2] # Face 2 120 | ] 121 | 122 | # Convert vertices and faces to triangles 123 | triangles = openstl.convert.triangles(vertices, faces) 124 | ``` 125 | 126 | ### Find Connected Components in Mesh Topology (Disjoint solids) 127 | ```python 128 | import openstl 129 | 130 | # Deserialize triangles from a file 131 | triangles = openstl.read("disjoint_solids.stl") 132 | 133 | # Convert triangles to vertices and faces 134 | vertices, faces = openstl.convert.verticesandfaces(triangles) 135 | 136 | # Identify connected components of faces 137 | connected_components = openstl.topology.find_connected_components(vertices, faces) 138 | 139 | # Print the result 140 | print(f"Number of connected components: {len(connected_components)}") 141 | for i, component in enumerate(connected_components): 142 | print(f"Faces of component {i + 1}: {component}") 143 | ``` 144 | 145 | 146 | ### Use with `Pytorch` 147 | ```python 148 | import openstl 149 | import torch 150 | 151 | quad = torch.Tensor(openstl.read("quad.stl")).to('cuda') 152 | 153 | # Rotating 154 | rotation_matrix = torch.Tensor([ 155 | [0,-1, 0], 156 | [1, 0, 0], 157 | [0, 0, 1] 158 | ]).to('cuda') 159 | rotated_quad = torch.matmul(rotation_matrix, quad.reshape(-1,3).T).T.reshape(-1,4,3) 160 | 161 | # Translating 162 | translation_vector = torch.Tensor([1,1,1]).to('cuda') 163 | quad[:,1:4,:] += translation_vector # Avoid translating normals 164 | 165 | # Scaling 166 | scale = 1000.0 167 | quad[:,1:4,:] *= scale # Avoid scaling normals 168 | ``` 169 | 170 | ### Read large STL file 171 | To read STL file with a large triangle count > **1 000 000**, the openstl buffer overflow safety must be unactivated with 172 | `openstl.set_activate_overflow_safety(False)` after import. Deactivating overflow safety may expose the application 173 | to a potential buffer overflow attack vector since the stl standard is not backed by a checksum. 174 | This can cause significant risks if openstl (and any other STL reader) is used as part of a service in a backend server for example. For 175 | domestic usage, ignore this warning. OpenSTl is the only stl reader to provide such default safety feature. 176 | 177 | # C++ Usage 178 | ### Read STL from file 179 | ```c++ 180 | #include 181 | 182 | std::ifstream file(filename, std::ios::binary); 183 | if (!file.is_open()) { 184 | std::cerr << "Error: Unable to open file '" << filename << "'" << std::endl; 185 | } 186 | 187 | // Deserialize the triangles in either binary or ASCII format 188 | std::vector triangles = openstl::deserializeStl(file); 189 | file.close(); 190 | ``` 191 | 192 | ### Write STL to a file 193 | ```c++ 194 | std::ofstream file(filename, std::ios::binary); 195 | if (!file.is_open()) { 196 | std::cerr << "Error: Unable to open file '" << filename << "'" << std::endl; 197 | } 198 | 199 | std::vector originalTriangles{}; // User triangles 200 | openstl::serialize(originalTriangles, file, openstl::StlFormat::Binary); // Or StlFormat::ASCII 201 | 202 | if (file.fail()) { 203 | std::cerr << "Error: Failed to write to file " << filename << std::endl; 204 | } else { 205 | std::cout << "File " << filename << " has been successfully written." << std::endl; 206 | } 207 | file.close(); 208 | ``` 209 | 210 | ### Serialize STL to a stream 211 | ```c++ 212 | std::stringstream ss; 213 | 214 | std::vector originalTriangles{}; // User triangles 215 | openstl::serialize(originalTriangles, ss, openstl::StlFormat::Binary); // Or StlFormat::ASCII 216 | ``` 217 | 218 | ### Convert Triangles :arrow_right: Vertices and Faces 219 | ```c++ 220 | using namespace openstl 221 | 222 | std::vector triangles = { 223 | // normal, vertices 0, vertices 1, vertices 2 224 | Triangle{{0.0f, 0.0f, 1.0f}, {1.0f, 1.0f, 1.0f}, {2.0f, 2.0f, 2.0f}, {3.0f, 3.0f, 3.0f}}, 225 | Triangle{{0.0f, 0.0f, 1.0f}, {2.0f, 2.0f, 2.0f}, {3.0f, 3.0f, 3.0f}, {4.0f, 4.0f, 4.0f}} 226 | }; 227 | 228 | const auto& [vertices, faces] = convertToVerticesAndFaces(triangles); 229 | ``` 230 | 231 | ### Convert Vertices and Faces :arrow_right: Triangles 232 | ```c++ 233 | using namespace openstl 234 | 235 | std::vector vertices = { 236 | Vec3{0.0f, 0.0f, 0.0f}, Vec3{1.0f, 1.0f, 1.0f}, Vec3{2.0f, 2.0f, 2.0f}, Vec3{3.0f, 3.0f, 3.0f} 237 | }; 238 | std::vector faces = { 239 | {0, 1, 2}, {3, 1, 2} 240 | }; 241 | 242 | const auto& triangles = convertToTriangles(vertices, faces); 243 | ``` 244 | 245 | ### Find Connected Components in Mesh Topology 246 | ```c++ 247 | using namespace openstl; 248 | 249 | // Convert to vertices and faces 250 | const auto& [vertices, faces] = convertToVerticesAndFaces(triangles); 251 | 252 | // Find connected components 253 | const auto& connected_components = findConnectedComponents(vertices, faces); 254 | 255 | std::cout << "Number of connected components: " << connected_components.size() << "\\n"; 256 | for (size_t i = 0; i < connected_components.size(); ++i) { 257 | std::cout << "Component " << i + 1 << ":\\n"; 258 | for (const auto& face : connected_components[i]) { 259 | std::cout << " {" << face[0] << ", " << face[1] << ", " << face[2] << "}\\n"; 260 | } 261 | } 262 | ``` 263 | **** 264 | # Integrate to your C++ codebase 265 | ### Smart method 266 | Include this repository with CMAKE Fetchcontent and link your executable/library to `openstl::core` library. 267 | 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. 268 | ```cmake 269 | include(FetchContent) 270 | FetchContent_Declare( 271 | openstl 272 | GIT_REPOSITORY https://github.com/Innoptech/OpenSTL.git 273 | GIT_TAG main 274 | GIT_SHALLOW TRUE 275 | GIT_PROGRESS TRUE 276 | ) 277 | FetchContent_MakeAvailable(openstl) 278 | ``` 279 | ### Naïve method 280 | Simply add [stl.h](modules/core/include/openstl/core/stl.h) to your codebase. 281 | 282 | # Test 283 | ```bash 284 | git clone https://github.com/Innoptech/OpenSTL 285 | mkdir OpenSTL/build && cd OpenSTL/build 286 | cmake -DOPENSTL_BUILD_TESTS=ON .. && cmake --build . 287 | ctest . 288 | ``` 289 | 290 | # Requirements 291 | C++17 or higher. 292 | 293 | 294 | # DISCLAIMER: STL File Format # 295 | 296 | The STL format is simple and widely used, but that simplicity brings important limitations: 297 | 298 | - No validation: STL files include no checksums, hashes, or structure verification, so corruption (e.g., truncated or malformed data) often goes undetected until parsing. 299 | - Sensitive to corruption: Errors during download, storage, or editing may only fail at runtime, causing crashes or undefined behavior. 300 | - Security concerns: Without built-in bounds checks, malformed STL files can potentially trigger buffer overflows, especially risky when handling untrusted input. 301 | 302 | Because STL offers no internal protection, applications must implement their own validation and error-handling when loading these files. 303 | -------------------------------------------------------------------------------- /python/core/src/stl.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #include "openstl/core/stl.h" 8 | #include "openstl/core/version.h" 9 | 10 | //------------------------------------------------------------------------------- 11 | // PYTHON BINDINGS 12 | //------------------------------------------------------------------------------- 13 | namespace py = pybind11; 14 | using namespace pybind11::literals; 15 | using namespace openstl; 16 | 17 | /** 18 | * @brief Template class for creating a strided span over a contiguous sequence of memory. 19 | * 20 | * This class provides a strided view over a contiguous sequence of memory, allowing iteration 21 | * over elements with a specified stride. 22 | * 23 | * @tparam VALUETYPE The type of elements stored in the span. 24 | * @tparam SIZE The stride size (number of elements to skip between each element). 25 | * @tparam PTRTYPE The type of the pointer to the underlying data. 26 | */ 27 | template 28 | class StridedSpan { 29 | // Iterator type for iterating over elements with stride 30 | class Iterator { 31 | public: 32 | using iterator_category = std::forward_iterator_tag; 33 | using difference_type = std::ptrdiff_t; 34 | using value_type = VALUETYPE; 35 | using pointer = VALUETYPE*; 36 | using reference = VALUETYPE&; 37 | 38 | explicit Iterator(const PTRTYPE* ptr) : ptr(ptr) {} 39 | 40 | const VALUETYPE& operator*() const { return *reinterpret_cast(ptr); } 41 | const VALUETYPE* operator->() const { return reinterpret_cast(ptr); } 42 | 43 | Iterator& operator++() { 44 | ptr += SIZE; 45 | return *this; 46 | } 47 | 48 | // Equality operator 49 | bool operator==(const Iterator& other) const { return ptr == other.ptr; } 50 | 51 | // Inequality operator 52 | bool operator!=(const Iterator& other) const { return !(*this == other); } 53 | 54 | private: 55 | const PTRTYPE* ptr; 56 | }; 57 | 58 | const PTRTYPE* data_; 59 | size_t size_; 60 | public: 61 | StridedSpan(const PTRTYPE* data, size_t size) : data_(data), size_(size) {} 62 | 63 | Iterator begin() const { return Iterator{data_}; } 64 | Iterator end() const { return Iterator{data_ + size_ * SIZE}; } 65 | size_t size() const {return size_;} 66 | const PTRTYPE* data() const {return data_;} 67 | }; 68 | 69 | 70 | namespace pybind11 { namespace detail { 71 | template <> struct type_caster> { 72 | public: 73 | PYBIND11_TYPE_CASTER(std::vector, _("TrianglesArray")); 74 | 75 | bool load(handle src, bool convert) 76 | { 77 | if ( (!convert) && (!py::array_t::check_(src)) ) 78 | return false; 79 | 80 | auto buf = py::array_t::ensure(src); 81 | if(!buf) 82 | return false; 83 | 84 | if (buf.ndim() != 3 || buf.shape(1) != 4 || buf.shape(2) != 3) 85 | return false; 86 | 87 | std::vector triangles{}; triangles.reserve(buf.shape(0)); 88 | StridedSpan stridedIter{buf.data(), (size_t)buf.shape(0)}; 89 | std::copy(std::begin(stridedIter), std::end(stridedIter), 90 | std::back_inserter(triangles)); 91 | 92 | value = triangles; 93 | return true; 94 | } 95 | 96 | static handle cast(const std::vector& src, return_value_policy /*policy*/, handle /* parent */) { 97 | py::array_t array( 98 | {static_cast(src.size()), 99 | static_cast(4), static_cast(3)}, 100 | {sizeof(Triangle), sizeof(Vec3), sizeof(float)}, 101 | (float*)src.data()); 102 | return array.release(); 103 | } 104 | }; 105 | }} // namespace pybind11::detail 106 | 107 | 108 | void serialize(py::module_ &m) { 109 | // Define getter and setter for the activateOverflowSafety option 110 | m.def("get_activate_overflow_safety", []() { 111 | return activateOverflowSafety(); 112 | }); 113 | 114 | m.def("set_activate_overflow_safety", [](bool value) { 115 | if (!value) { 116 | py::print("Warning: Deactivating overflow safety may expose the application to potential buffer overflow risks.", 117 | py::module_::import("sys").attr("stderr")); 118 | } 119 | activateOverflowSafety() = value; 120 | }); 121 | 122 | py::enum_(m, "format") 123 | .value("ascii", StlFormat::ASCII) 124 | .value("binary", StlFormat::Binary) 125 | .export_values(); 126 | 127 | m.def("write", [](const std::string &filename, 128 | const py::array_t &array, 129 | StlFormat format=openstl::StlFormat::Binary){ 130 | py::scoped_ostream_redirect stream(std::cerr,py::module_::import("sys").attr("stderr")); 131 | std::ofstream file(filename, std::ios::binary); 132 | if (!file.is_open()) { 133 | std::cerr << "Error: Unable to open file '" << filename << "'." << std::endl; 134 | return false; 135 | } 136 | 137 | auto buf = py::array_t::ensure(array); 138 | if(!buf) 139 | return false; 140 | 141 | if (buf.ndim() != 3 || buf.shape(1) != 4 || buf.shape(2) != 3) 142 | return false; 143 | 144 | StridedSpan stridedIter{buf.data(), (size_t)buf.shape(0)}; 145 | openstl::serialize(stridedIter, file, format); 146 | 147 | if (file.fail()) { 148 | std::cerr << "Error: Failed to write to file '" << filename << "'." << std::endl; 149 | } 150 | file.close(); 151 | return true; 152 | },"filename"_a, "triangles"_a, "StlFormat"_a=openstl::StlFormat::Binary, "Serialize a STL to a file"); 153 | 154 | m.def("read", [](const std::string &filename) { 155 | py::scoped_ostream_redirect stream(std::cerr, py::module_::import("sys").attr("stderr")); 156 | std::ifstream file(filename, std::ios::binary); 157 | if (!file.is_open()) { 158 | std::cerr << "Error: Unable to open file '" << filename << "'." << std::endl; 159 | return std::vector{}; 160 | } 161 | 162 | // Deserialize the triangles in either binary or ASCII format 163 | return openstl::deserializeStl(file); 164 | }, "filename"_a, "Deserialize a STl from a file", py::return_value_policy::move); 165 | } 166 | 167 | 168 | namespace openstl 169 | { 170 | enum class Convert { VERTICES_AND_FACES=0, TRIANGLES}; 171 | }; 172 | 173 | void convertSubmodule(py::module_ &_m) 174 | { 175 | auto m = _m.def_submodule("convert", "A submodule to convert mesh representations"); 176 | 177 | m.def("verticesandfaces", []( 178 | const py::array_t &array 179 | ) 180 | -> std::tuple, 181 | py::array_t> 182 | { 183 | py::scoped_ostream_redirect stream(std::cerr,py::module_::import("sys").attr("stderr")); 184 | auto buf = py::array_t::ensure(array); 185 | if(!buf){ 186 | std::cerr << "Input array cannot be interpreted as a mesh.\n"; 187 | return {}; 188 | } 189 | if (buf.ndim() != 3 || buf.shape(1) != 4 || buf.shape(2) != 3){ 190 | std::cerr << "Input array cannot be interpreted as a mesh.\n"; 191 | return {}; 192 | } 193 | 194 | StridedSpan stridedIter{buf.data(), (size_t)buf.shape(0)}; 195 | const auto& verticesAndFaces = convertToVerticesAndFaces(stridedIter); 196 | const auto& vertices = std::get<0>(verticesAndFaces); 197 | const auto& faces = std::get<1>(verticesAndFaces); 198 | 199 | return std::make_tuple( 200 | py::array_t( 201 | {static_cast(vertices.size()),static_cast(3)}, 202 | {sizeof(Vec3), sizeof(float)}, 203 | (const float*)vertices.data()), 204 | py::array_t( 205 | {static_cast(faces.size()),static_cast(3)}, 206 | {sizeof(Face), sizeof(size_t)}, 207 | (const size_t*)faces.data()) 208 | ); 209 | }, "triangles"_a, "Convert the mesh to a format 'vertices-and-face-indices'"); 210 | 211 | 212 | m.def("triangles", []( 213 | const py::array_t &vertices, 214 | const py::array_t &faces 215 | ) -> std::vector 216 | { 217 | py::scoped_ostream_redirect stream(std::cerr,py::module_::import("sys").attr("stderr")); 218 | auto vbuf = py::array_t::ensure(vertices); 219 | if(!vbuf){ 220 | std::cerr << "Vertices input array cannot be interpreted as a mesh.\n"; 221 | return {}; 222 | } 223 | if (vbuf.ndim() != 2 || vbuf.shape(1) != 3){ 224 | std::cerr << "Vertices input array cannot be interpreted as a mesh. Shape must be N x 3.\n"; 225 | return {}; 226 | } 227 | 228 | auto fbuf = py::array_t::ensure(faces); 229 | if(!fbuf){ 230 | std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; 231 | return {}; 232 | } 233 | if (fbuf.ndim() != 2 || vbuf.shape(1) != 3){ 234 | std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; 235 | std::cerr << "Shape must be N x 3 (v0, v1, v2).\n"; 236 | return {}; 237 | } 238 | 239 | StridedSpan verticesIter{vbuf.data(), (size_t)vbuf.shape(0)}; 240 | StridedSpan facesIter{fbuf.data(), (size_t)fbuf.shape(0)}; 241 | return convertToTriangles(verticesIter, facesIter); 242 | }, "vertices"_a,"faces"_a, "Convert the mesh from vertices and faces to triangles"); 243 | } 244 | 245 | void topologySubmodule(py::module_ &_m) 246 | { 247 | auto m = _m.def_submodule("topology", "A submodule for analyzing and segmenting connected components in mesh topology."); 248 | 249 | m.def("find_connected_components", []( 250 | const py::array_t &vertices, 251 | const py::array_t &faces 252 | ) -> std::vector> 253 | { 254 | py::scoped_ostream_redirect stream(std::cerr,py::module_::import("sys").attr("stderr")); 255 | auto vbuf = py::array_t::ensure(vertices); 256 | if(!vbuf){ 257 | std::cerr << "Vertices input array cannot be interpreted as a mesh.\n"; 258 | return {}; 259 | } 260 | if (vbuf.ndim() != 2 || vbuf.shape(1) != 3){ 261 | std::cerr << "Vertices input array cannot be interpreted as a mesh. Shape must be N x 3.\n"; 262 | return {}; 263 | } 264 | 265 | auto fbuf = py::array_t::ensure(faces); 266 | if(!fbuf){ 267 | std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; 268 | return {}; 269 | } 270 | if (fbuf.ndim() != 2 || vbuf.shape(1) != 3){ 271 | std::cerr << "Faces input array cannot be interpreted as a mesh.\n"; 272 | std::cerr << "Shape must be N x 3 (v0, v1, v2).\n"; 273 | return {}; 274 | } 275 | 276 | StridedSpan verticesIter{vbuf.data(), (size_t)vbuf.shape(0)}; 277 | StridedSpan facesIter{fbuf.data(), (size_t)fbuf.shape(0)}; 278 | return findConnectedComponents(verticesIter, facesIter); 279 | }, "vertices"_a,"faces"_a, "Convert the mesh from vertices and faces to triangles"); 280 | } 281 | 282 | PYBIND11_MODULE(openstl, m) { 283 | serialize(m); 284 | convertSubmodule(m); 285 | topologySubmodule(m); 286 | m.attr("__version__") = OPENSTL_PROJECT_VER; 287 | m.doc() = "A simple STL serializer and deserializer"; 288 | 289 | PYBIND11_NUMPY_DTYPE(Vec3, x, y, z); 290 | PYBIND11_NUMPY_DTYPE(Triangle, normal, v0, v1, v2, attribute_byte_count); 291 | } -------------------------------------------------------------------------------- /tests/core/src/deserialize.test.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "openstl/tests/testutils.h" 4 | #include "openstl/core/stl.h" 5 | #include 6 | #include 7 | 8 | using namespace openstl; 9 | 10 | 11 | static std::string oneTriangleBlock( 12 | const std::string& normal, const std::string& v0, const std::string& v1, const std::string& v2, 13 | const std::string& outer="outer loop") 14 | { 15 | std::ostringstream ss; 16 | ss << "facet normal " << normal << "\n"; 17 | ss << outer << "\n"; 18 | ss << "vertex " << v0 << "\n"; 19 | ss << "vertex " << v1 << "\n"; 20 | ss << "vertex " << v2 << "\n"; 21 | ss << "endloop\n"; 22 | ss << "endfacet\n"; 23 | return ss.str(); 24 | } 25 | 26 | TEST_CASE("Deserialize ASCII STL: single triangle", "[openstl][ascii]") { 27 | const std::string stl_text = 28 | "solid name\n" 29 | "facet normal 0.1 0.2 1.0\n" 30 | "outer loop\n" 31 | "vertex 0.0 0.0 0.0\n" 32 | "vertex 1.0 0.0 0.0\n" 33 | "vertex 0.0 1.0 0.0\n" 34 | "endloop\n" 35 | "endfacet\n" 36 | "endsolid name\n"; 37 | 38 | std::stringstream ss1(stl_text); 39 | auto triangles = deserializeAsciiStl(ss1); 40 | 41 | REQUIRE(triangles.size() == 1); 42 | REQUIRE(triangles[0].normal.x == 0.1f); 43 | REQUIRE(triangles[0].normal.y == 0.2f); 44 | REQUIRE(triangles[0].normal.z == 1.0f); 45 | 46 | REQUIRE(triangles[0].v0.x == 0.0f); 47 | REQUIRE(triangles[0].v0.y == 0.0f); 48 | REQUIRE(triangles[0].v0.z == 0.0f); 49 | 50 | REQUIRE(triangles[0].v1.x == 1.0f); 51 | REQUIRE(triangles[0].v1.y == 0.0f); 52 | REQUIRE(triangles[0].v1.z == 0.0f); 53 | 54 | REQUIRE(triangles[0].v2.x == 0.0f); 55 | REQUIRE(triangles[0].v2.y == 1.0f); 56 | REQUIRE(triangles[0].v2.z == 0.0f); 57 | 58 | std::stringstream ss2(stl_text); 59 | auto triangles_auto = deserializeStl(ss2); 60 | REQUIRE(triangles.size() == triangles_auto.size()); 61 | } 62 | 63 | TEST_CASE("Deserialize ASCII STL: multiple triangles", "[openstl][ascii]") { 64 | const std::string stl_text = 65 | "solid name\n" 66 | "facet normal 0.1 0.2 1.0\n" 67 | "outer loop\n" 68 | "vertex 0.0 0.0 0.0\n" 69 | "vertex 1.0 0.0 0.0\n" 70 | "vertex 0.0 1.0 0.0\n" 71 | "endloop\n" 72 | "endfacet\n" 73 | "facet normal 0.0 0.0 1.0\n" 74 | "outer loop\n" 75 | "vertex 0.0 0.0 0.0\n" 76 | "vertex 0.0 1.0 0.0\n" 77 | "vertex 1.0 0.0 0.0\n" 78 | "endloop\n" 79 | "endfacet\n" 80 | "endsolid name\n"; 81 | 82 | std::stringstream ss1(stl_text); 83 | auto triangles = deserializeAsciiStl(ss1); 84 | 85 | REQUIRE(triangles.size() == 2); 86 | REQUIRE(triangles[0].normal.x == 0.1f); 87 | REQUIRE(triangles[0].normal.y == 0.2f); 88 | REQUIRE(triangles[0].normal.z == 1.0f); 89 | 90 | std::stringstream ss2(stl_text); 91 | auto triangles_auto = deserializeStl(ss2); 92 | REQUIRE(triangles.size() == triangles_auto.size()); 93 | } 94 | 95 | TEST_CASE("Deserialize ASCII STL: scientific notation parses (issue #25)", "[openstl][ascii][sci]") { 96 | std::stringstream ss; 97 | ss << "solid name\n"; 98 | ss << oneTriangleBlock( 99 | "3.530327e-01 -3.218319e-01 -8.785170e-01", 100 | "5.502911e-01 -7.287032e-01 3.099700e-01", 101 | "2.905658e-01 -3.847714e-01 7.960480e-02", 102 | "4.099400e-01 -2.538241e-01 7.960480e-02"); 103 | ss << "endsolid name\n"; 104 | 105 | auto tris = deserializeAsciiStl(ss); 106 | REQUIRE(tris.size() == 1); 107 | REQUIRE_THAT(tris[0].normal.x, Catch::Matchers::WithinAbs( 3.530327e-01f, 1e-6f)); 108 | REQUIRE_THAT(tris[0].normal.y, Catch::Matchers::WithinAbs(-3.218319e-01f, 1e-6f)); 109 | REQUIRE_THAT(tris[0].normal.z, Catch::Matchers::WithinAbs(-8.785170e-01f, 1e-6f)); 110 | REQUIRE_THAT(tris[0].v0.x, Catch::Matchers::WithinAbs( 5.502911e-01f, 1e-6f)); 111 | REQUIRE_THAT(tris[0].v2.z, Catch::Matchers::WithinAbs( 7.960480e-02f, 1e-6f)); 112 | } 113 | 114 | TEST_CASE("Deserialize ASCII STL: keywords are case-insensitive", "[openstl][ascii][case]") { 115 | std::stringstream ss; 116 | ss << "solid s\n"; 117 | ss << "FACET NORMAL 1E+00 0E+00 0E+00\n"; 118 | ss << "OUTER LOOP\n"; 119 | ss << "VERTEX 0E+00 0E+00 0E+00\n"; 120 | ss << "VERTEX 1E+00 0E+00 0E+00\n"; 121 | ss << "VERTEX 0E+00 1E+00 0E+00\n"; 122 | ss << "ENDLOOP\nENDFACET\nENDSOLID s\n"; 123 | 124 | auto tris = deserializeAsciiStl(ss); 125 | REQUIRE(tris.size() == 1); 126 | REQUIRE_THAT(tris[0].normal.x, Catch::Matchers::WithinAbs(1.0f, 1e-6f)); 127 | } 128 | 129 | TEST_CASE("Deserialize ASCII STL: Windows CRLF endings are tolerated", "[openstl][ascii][crlf]") { 130 | std::string text; 131 | text = "solid s\r\n"; 132 | text += oneTriangleBlock("1.0 0.0 0.0", "0 0 0", "1 0 0", "0 1 0"); 133 | std::replace(text.begin(), text.end(), '\n', '\r'); // make everything CR 134 | // Ensure CRLF pairs exist (simulate typical CRLF): we’ll craft quickly: 135 | // For simplicity, rebuild with \r\n pairs: 136 | text = "solid s\r\n"; 137 | text += "facet normal 1.0 0.0 0.0\r\n"; 138 | text += "outer loop\r\n"; 139 | text += "vertex 0 0 0\r\n"; 140 | text += "vertex 1 0 0\r\n"; 141 | text += "vertex 0 1 0\r\n"; 142 | text += "endloop\r\nendfacet\r\nendsolid s\r\n"; 143 | 144 | std::stringstream ss(text); 145 | auto tris = deserializeAsciiStl(ss); 146 | REQUIRE(tris.size() == 1); 147 | REQUIRE_THAT(tris[0].v1.x, Catch::Matchers::WithinAbs(1.0f, 1e-6f)); 148 | } 149 | 150 | TEST_CASE("Deserialize ASCII STL: extra tokens after numbers are ignored", "[openstl][ascii][garbage]") { 151 | std::stringstream ss; 152 | ss << "solid s\n"; 153 | ss << "facet normal 0 0 1 extra tokens here\n"; 154 | ss << "outer loop\n"; 155 | ss << "vertex 0 0 0 trailing\n"; 156 | ss << "vertex 1 0 0 garbage\n"; 157 | ss << "vertex 0 1 0 more_garbage\n"; 158 | ss << "endloop\nendfacet\nendsolid s\n"; 159 | 160 | auto tris = deserializeAsciiStl(ss); 161 | REQUIRE(tris.size() == 1); 162 | REQUIRE_THAT(tris[0].normal.z, Catch::Matchers::WithinAbs(1.0f, 1e-6f)); 163 | } 164 | 165 | TEST_CASE("Deserialize ASCII STL: malformed vertex fails fast (missing coord)", "[openstl][ascii][error]") { 166 | std::stringstream ss; 167 | ss << "solid s\n"; 168 | ss << "facet normal 0 0 1\n"; 169 | ss << "outer loop\n"; 170 | ss << "vertex 0 0\n"; // <-- missing Z 171 | ss << "vertex 1 0 0\n"; 172 | ss << "vertex 0 1 0\n"; 173 | ss << "endloop\nendfacet\nendsolid s\n"; 174 | 175 | REQUIRE_THROWS_AS(deserializeAsciiStl(ss), std::runtime_error); 176 | } 177 | 178 | TEST_CASE("Deserialize ASCII STL: unexpected EOF fails fast", "[openstl][ascii][eof]") { 179 | std::stringstream ss; 180 | ss << "solid s\n"; 181 | ss << "facet normal 0 0 1\n"; 182 | ss << "outer loop\n"; 183 | ss << "vertex 0 0 0\n"; 184 | // stream ends abruptly before vertex 2/3 185 | REQUIRE_THROWS_AS(deserializeAsciiStl(ss), std::runtime_error); 186 | } 187 | 188 | TEST_CASE("Deserialize ASCII STL: non-facet text is ignored (0 triangles)", "[openstl][ascii][ignore]") { 189 | std::stringstream ss; 190 | ss << "solid s\n"; 191 | ss << "this is a comment\n"; 192 | ss << "endsolid s\n"; 193 | auto tris = deserializeAsciiStl(ss); 194 | REQUIRE(tris.empty()); 195 | } 196 | 197 | TEST_CASE("Deserialize Binary STL", "[openstl]") { 198 | 199 | SECTION("KEY") 200 | { 201 | std::ifstream file(testutils::getTestObjectPath(testutils::TESTOBJECT::KEY), std::ios::binary); 202 | REQUIRE(file.is_open()); 203 | auto triangles = deserializeBinaryStl(file); 204 | REQUIRE(triangles.size() == 12); 205 | REQUIRE_THAT(triangles.at(0).normal.x, Catch::Matchers::WithinAbs(-1.0, 1e-6)); 206 | REQUIRE_THAT(triangles.at(1).normal.z, Catch::Matchers::WithinAbs(-1.0, 1e-6)); 207 | REQUIRE(triangles.at(0).attribute_byte_count == 0); 208 | 209 | file.clear(); file.seekg(0); 210 | auto triangles_auto = deserializeStl(file); 211 | REQUIRE(triangles.size() == triangles_auto.size()); 212 | } 213 | SECTION("BALL") 214 | { 215 | std::ifstream file(testutils::getTestObjectPath(testutils::TESTOBJECT::BALL), std::ios::binary); 216 | REQUIRE(file.is_open()); 217 | auto triangles = deserializeBinaryStl(file); 218 | REQUIRE(triangles.size() == 6162); 219 | 220 | file.clear(); file.seekg(0); 221 | auto triangles_auto = deserializeStl(file); 222 | REQUIRE(triangles.size() == triangles_auto.size()); 223 | } 224 | SECTION("WASHER") 225 | { 226 | std::ifstream file(testutils::getTestObjectPath(testutils::TESTOBJECT::WASHER), std::ios::binary); 227 | REQUIRE(file.is_open()); 228 | auto triangles = deserializeBinaryStl(file); 229 | REQUIRE(triangles.size() == 424); 230 | 231 | file.clear(); file.seekg(0); 232 | auto triangles_auto = deserializeStl(file); 233 | REQUIRE(triangles.size() == triangles_auto.size()); 234 | } 235 | } 236 | 237 | TEST_CASE("Binary STL Serialization/Deserialization Security and Integrity Tests", 238 | "[openstl][security][stl][serialization][deserialization][security]") 239 | { 240 | SECTION("Incomplete triangle data - incomplete_triangle_data.stl") { 241 | const std::vector &triangles = testutils::createTestTriangle(); 242 | const std::string filename{"incomplete_triangle_data.stl"}; 243 | testutils::createIncompleteTriangleData(triangles, filename); 244 | 245 | std::ifstream file(filename, std::ios::binary); 246 | REQUIRE(file.is_open()); 247 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 248 | } 249 | SECTION("Test deserialization with corrupted header (invalid characters)") { 250 | const std::vector& triangles = testutils::createTestTriangle(); 251 | const std::string filename = "corrupted_header.stl"; 252 | testutils::createCorruptedHeaderInvalidChars(triangles, filename); // Generate the file with invalid characters in the header 253 | 254 | std::ifstream file(filename, std::ios::binary); 255 | REQUIRE(file.is_open()); 256 | 257 | std::vector deserialized_triangles; 258 | CHECK_NOTHROW(deserialized_triangles = deserializeBinaryStl(file)); 259 | REQUIRE(testutils::checkTrianglesEqual(deserialized_triangles, triangles)); 260 | } 261 | SECTION("Test deserialization with corrupted header (excess data after header)") { 262 | const std::vector &triangles = testutils::createTestTriangle(); 263 | const std::string filename{"excess_data_after_header.stl"}; 264 | testutils::createCorruptedHeaderExcessData(triangles, 265 | filename); // Generate the file with excess data after the header 266 | 267 | std::ifstream file(filename, std::ios::binary); 268 | REQUIRE(file.is_open()); 269 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 270 | } 271 | SECTION("Test deserialization with excessive triangle count") { 272 | const std::vector &triangles = testutils::createTestTriangle(); 273 | const std::string filename{"excessive_triangle_count.stl"}; 274 | testutils::createExcessiveTriangleCount(triangles,filename); // Generate the file with an excessive triangle count 275 | 276 | std::ifstream file(filename, std::ios::binary); 277 | REQUIRE(file.is_open()); 278 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 279 | } 280 | SECTION("Test deserialization with the maximum number of triangles") { 281 | const std::string filename = "max_triangles.stl"; 282 | 283 | // Create a file with exactly MAX_TRIANGLES triangles 284 | std::vector triangles(MAX_TRIANGLES); 285 | testutils::createStlWithTriangles(triangles, filename); 286 | 287 | std::ifstream file(filename, std::ios::binary); 288 | REQUIRE(file.is_open()); 289 | 290 | // Test that deserialization works correctly for MAX_TRIANGLES 291 | std::vector deserialized_triangles; 292 | CHECK_NOTHROW(deserialized_triangles = deserializeBinaryStl(file)); 293 | REQUIRE(deserialized_triangles.size() == MAX_TRIANGLES); 294 | } 295 | SECTION("Test deserialization exceeding the maximum number of triangles") { 296 | const std::string filename = "exceeding_triangles.stl"; 297 | 298 | // Create a file with more than MAX_TRIANGLES triangles 299 | std::vector triangles(MAX_TRIANGLES+1); 300 | testutils::createStlWithTriangles(triangles, filename); 301 | 302 | std::ifstream file(filename, std::ios::binary); 303 | REQUIRE(file.is_open()); 304 | 305 | // Test that deserialization throws an exception for exceeding MAX_TRIANGLES 306 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 307 | } 308 | SECTION("Test deserialization exceeding the maximum number of triangles with deactivated safety") { 309 | const std::string filename = "exceeding_triangles.stl"; 310 | 311 | // Create a file with more than MAX_TRIANGLES triangles 312 | std::vector triangles(MAX_TRIANGLES+1); 313 | testutils::createStlWithTriangles(triangles, filename); 314 | 315 | std::ifstream file(filename, std::ios::binary); 316 | REQUIRE(file.is_open()); 317 | 318 | // Deactivate buffer overflow safety 319 | activateOverflowSafety() = false; 320 | CHECK_NOTHROW(deserializeBinaryStl(file)); 321 | } 322 | SECTION("Test deserialization with an empty file") { 323 | const std::string filename{"empty_triangles.stl"}; 324 | testutils::createEmptyStlFile(filename); // Generate an empty file 325 | 326 | std::ifstream file(filename, std::ios::binary); 327 | REQUIRE(file.is_open()); 328 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 329 | } 330 | SECTION("Buffer overflow on triangle count - buffer_overflow_triangle_count.stl") { 331 | std::string filename = "buffer_overflow_triangle_count.stl"; 332 | testutils::createBufferOverflowOnTriangleCount(filename); 333 | 334 | std::ifstream file(filename, std::ios::binary); 335 | REQUIRE(file.is_open()); 336 | CHECK_THROWS_AS(deserializeBinaryStl(file), std::runtime_error); 337 | } 338 | SECTION("Test deserialization with corrupted header (invalid characters) - corrupted_header_invalid_chars.stl") { 339 | const std::vector& triangles = testutils::createTestTriangle(); 340 | const std::string filename = "corrupted_header_invalid_chars.stl"; 341 | testutils::createCorruptedHeaderInvalidChars(triangles, filename); // Generate the file with invalid characters in the header 342 | 343 | std::ifstream file(filename, std::ios::binary); 344 | REQUIRE(file.is_open()); 345 | 346 | // Deserialize the STL file, ignoring the header content 347 | auto deserialized_triangles = deserializeBinaryStl(file); 348 | 349 | // Check that the deserialized triangles match the expected count and data 350 | testutils::checkTrianglesEqual(deserialized_triangles, triangles); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /modules/core/include/openstl/core/stl.h: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Innoptech 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | #ifndef OPENSTL_OPENSTL_SERIALIZE_H 26 | #define OPENSTL_OPENSTL_SERIALIZE_H 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include 39 | #include 40 | #include 41 | #include 42 | #include 43 | #include 44 | 45 | #define MAX_TRIANGLES 1000000 46 | 47 | namespace openstl 48 | { 49 | // Disable padding for the structure 50 | #pragma pack(push, 1) 51 | struct Vec3 { 52 | float x, y, z; 53 | }; 54 | 55 | struct Triangle { 56 | Vec3 normal, v0, v1, v2; 57 | uint16_t attribute_byte_count; 58 | }; 59 | #pragma pack(pop) 60 | 61 | //--------------------------------------------------------------------------------------------------------- 62 | // Serialize 63 | //--------------------------------------------------------------------------------------------------------- 64 | enum class StlFormat { ASCII, Binary }; 65 | 66 | /** 67 | * @brief Serialize a vector of triangles to an ASCII STL format and write it to the provided stream. 68 | * 69 | * This function writes the vector of triangles to the stream in ASCII STL format, where each triangle 70 | * is represented by its normal vector and three vertices. 71 | * 72 | * @tparam Stream The type of the output stream. 73 | * @param triangles The vector of triangles to serialize. 74 | * @param stream The output stream to write the serialized data to. 75 | */ 76 | template 77 | void serializeAsciiStl(const Container& triangles, Stream& stream) { 78 | stream << "solid\n"; 79 | for (const auto& tri : triangles) { 80 | stream << "facet normal " << tri.normal.x << " " << tri.normal.y << " " << tri.normal.z << std::endl; 81 | stream << "outer loop" << std::endl; 82 | stream << "vertex " << tri.v0.x << " " << tri.v0.y << " " << tri.v0.z << std::endl; 83 | stream << "vertex " << tri.v1.x << " " << tri.v1.y << " " << tri.v1.z << std::endl; 84 | stream << "vertex " << tri.v2.x << " " << tri.v2.y << " " << tri.v2.z << std::endl; 85 | stream << "endloop" << std::endl; 86 | stream << "endfacet" << std::endl; 87 | } 88 | stream << "endsolid\n"; 89 | } 90 | 91 | /** 92 | * @brief Serialize a vector of triangles in binary STL format and write to a stream. 93 | * 94 | * @tparam Stream The type of the output stream. 95 | * @param triangles The vector of triangles to serialize. 96 | * @param stream The output stream to write the serialized data. 97 | */ 98 | template 99 | void serializeBinaryStl(const Container& triangles, Stream& stream) { 100 | // Write header (80 bytes for comments) 101 | char header[80] = "STL Exported by OpenSTL [https://github.com/Innoptech/OpenSTL]"; 102 | stream.write(header, sizeof(header)); 103 | 104 | // Write triangle count (4 bytes) 105 | auto triangleCount = static_cast(triangles.size()); 106 | stream.write(reinterpret_cast(&triangleCount), sizeof(triangleCount)); 107 | 108 | // Write triangles 109 | for (const auto& tri : triangles) { 110 | stream.write(reinterpret_cast(&tri), sizeof(Triangle)); 111 | } 112 | } 113 | 114 | 115 | /** 116 | * @brief Serialize a vector of triangles in the specified STL format and write to a stream. 117 | * 118 | * @tparam Stream The type of the output stream. 119 | * @param triangles The vector of triangles to serialize. 120 | * @param stream The output stream to write the serialized data. 121 | * @param format The format of the STL file (ASCII or binary). 122 | */ 123 | template 124 | inline void serialize(const Container& triangles, Stream& stream, StlFormat format) { 125 | switch (format) { 126 | case StlFormat::ASCII: 127 | serializeAsciiStl(triangles, stream); 128 | break; 129 | case StlFormat::Binary: 130 | serializeBinaryStl(triangles, stream); 131 | break; 132 | } 133 | } 134 | 135 | //--------------------------------------------------------------------------------------------------------- 136 | // Deserialize 137 | //--------------------------------------------------------------------------------------------------------- 138 | 139 | /** 140 | * A library-level configuration to activate/deactivate the buffer overflow safety 141 | * @return 142 | */ 143 | inline bool& activateOverflowSafety() { 144 | static bool safety_enabled = true; 145 | return safety_enabled; 146 | } 147 | 148 | /** 149 | * @brief Trim leading whitespace from a string_view. 150 | * 151 | * @param sv Input string view. 152 | * @return A string_view with leading whitespace removed. 153 | */ 154 | inline std::string_view ltrim(std::string_view sv) noexcept { 155 | size_t i{0}; 156 | while (i < sv.size() && std::isspace(static_cast(sv[i]))) ++i; 157 | return sv.substr(i); 158 | } 159 | 160 | /** 161 | * @brief Case-insensitive prefix match. 162 | * 163 | * @param hay Line to inspect. 164 | * @param needle Expected prefix. 165 | * @return True if hay starts with needle (ignoring case). 166 | */ 167 | inline bool istarts_with(std::string_view hay, std::string_view needle) noexcept { 168 | if (hay.size() < needle.size()) return false; 169 | for (size_t i = 0; i < needle.size(); ++i) { 170 | const auto a = std::tolower(static_cast(hay[i])); 171 | const auto b = std::tolower(static_cast(needle[i])); 172 | if (a != b) return false; 173 | } 174 | return true; 175 | } 176 | 177 | /** 178 | * @brief Read a line or throw if the stream ends unexpectedly. 179 | * 180 | * @tparam Stream Input stream type. 181 | * @param s Stream to read from. 182 | * @param context Context string for error reporting. 183 | * @return The read line (without newline). 184 | * 185 | * @throws std::runtime_error If EOF is reached unexpectedly. 186 | */ 187 | template 188 | inline std::string getline_or_throw(Stream& s, const char* context) { 189 | std::string out{}; 190 | if (!std::getline(s, out)) { 191 | throw std::runtime_error(std::string("Unexpected end of stream while reading ") + context); 192 | } 193 | return out; 194 | } 195 | 196 | /** 197 | * @brief Parse three floats from a line after a given ASCII STL keyword. 198 | * 199 | * Accepts scientific notation; uses C locale for numeric parsing. 200 | * 201 | * @param line Input line (whitespace tolerated). 202 | * @param keyword Expected leading keyword ("facet normal" or "vertex"). 203 | * @param a Output float #1. 204 | * @param b Output float #2. 205 | * @param c Output float #3. 206 | * 207 | * @throws std::runtime_error On keyword mismatch or parsing failure. 208 | */ 209 | inline void parse_three_floats_after(std::string_view line, 210 | std::string_view keyword, 211 | float& a, float& b, float& c) 212 | { 213 | line = ltrim(line); 214 | if (!istarts_with(line, keyword)) { 215 | throw std::runtime_error("Expected keyword '" + std::string(keyword) + "' not found."); 216 | } 217 | std::string_view rest = ltrim(line.substr(keyword.size())); 218 | 219 | // Use classic C locale to ensure '.' and scientific notation work regardless of global locale. 220 | std::istringstream iss{std::string(rest)}; 221 | iss.imbue(std::locale::classic()); 222 | if (!(iss >> a >> b >> c)) { 223 | throw std::runtime_error("Failed to parse three floats after '" + std::string(keyword) + "'."); 224 | } 225 | } 226 | 227 | /** 228 | * @brief Read one vertex from an ASCII STL vertex line. 229 | * 230 | * Expected syntax: "vertex x y z" 231 | * 232 | * @tparam Stream Input stream type. 233 | * @param stream Stream positioned at a vertex line. 234 | * @param v Output vertex coordinates. 235 | * 236 | * @throws std::runtime_error On malformed or missing vertex line. 237 | */ 238 | template 239 | inline void readVertex(Stream& stream, Vec3& v) { 240 | const std::string line = getline_or_throw(stream, "vertex line"); 241 | parse_three_floats_after(std::string_view(line), "vertex", v.x, v.y, v.z); 242 | } 243 | 244 | /** 245 | * @brief Deserialize triangles from an ASCII STL input stream. 246 | * 247 | * Supports scientific notation and variable whitespace. 248 | * Ignores unrelated lines (endfacet/endsolid/comments). 249 | * 250 | * @tparam Stream Input stream type. 251 | * @param stream Stream containing ASCII STL data. 252 | * @param max_triangles Optional safety bound (default: unlimited). 253 | * 254 | * @return Vector of parsed triangles. 255 | * 256 | * @throws std::runtime_error On malformed geometry or size overflow. 257 | */ 258 | template 259 | inline std::vector deserializeAsciiStl( 260 | Stream& stream, 261 | std::size_t max_triangles = std::numeric_limits::max()) 262 | { 263 | std::vector tris; 264 | std::string raw; 265 | 266 | while (std::getline(stream, raw)) { 267 | std::string_view line = ltrim(std::string_view(raw)); 268 | 269 | if (!istarts_with(line, "facet normal")) { 270 | // Tolerate other lines (solid/endsolid/endfacet/endloop/comments). 271 | continue; 272 | } 273 | 274 | Triangle t{}; 275 | parse_three_floats_after(line, "facet normal", t.normal.x, t.normal.y, t.normal.z); 276 | 277 | // Read and optionally validate the 'outer loop' line. 278 | const std::string outer = getline_or_throw(stream, "'outer loop'"); 279 | // If you want strictness, uncomment: 280 | // if (!istarts_with(ltrim(std::string_view(outer)), "outer loop")) { 281 | // throw std::runtime_error("Expected 'outer loop' after 'facet normal'."); 282 | // } 283 | 284 | // Three vertices 285 | readVertex(stream, t.v0); 286 | readVertex(stream, t.v1); 287 | readVertex(stream, t.v2); 288 | 289 | tris.push_back(t); 290 | if (tris.size() > max_triangles) { 291 | throw std::runtime_error("Triangle count exceeds the maximum allowable value."); 292 | } 293 | } 294 | 295 | return tris; 296 | } 297 | 298 | /** 299 | * @brief Deserialize a binary STL file from a stream and convert it to a vector of triangles. 300 | * 301 | * @tparam Stream The type of the input stream. 302 | * @param stream The input stream from which to read the binary STL data. 303 | * @return A vector of triangles representing the geometry from the binary STL file. 304 | */ 305 | template 306 | std::vector deserializeBinaryStl(Stream& stream) { 307 | auto start_pos = stream.tellg(); 308 | stream.seekg(0, std::ios::end); 309 | auto end_pos = stream.tellg(); 310 | stream.seekg(start_pos); 311 | 312 | if (end_pos - start_pos < 84) { 313 | throw std::runtime_error("File is too small to be a valid STL file."); 314 | } 315 | 316 | char header[80]; 317 | stream.read(header, sizeof(header)); 318 | 319 | if (stream.gcount() != sizeof(header)) { 320 | throw std::runtime_error("Failed to read the full header. Possible corruption or incomplete file."); 321 | } 322 | 323 | uint32_t triangle_qty; 324 | stream.read(reinterpret_cast(&triangle_qty), sizeof(triangle_qty)); 325 | 326 | if (stream.gcount() != sizeof(triangle_qty) || stream.fail() || stream.eof()) { 327 | throw std::runtime_error("Failed to read the triangle count. Possible corruption or incomplete file."); 328 | } 329 | 330 | // Apply the triangle count limit only if activateOverflowSafety is true 331 | if (activateOverflowSafety() && triangle_qty > MAX_TRIANGLES) { 332 | throw std::runtime_error("Triangle count exceeds the maximum allowable value."); 333 | } 334 | 335 | std::size_t expected_data_size = sizeof(Triangle) * triangle_qty; 336 | 337 | if (end_pos - stream.tellg() < static_cast(expected_data_size)) { 338 | throw std::runtime_error("Not enough data in stream for the expected triangle count."); 339 | } 340 | 341 | std::vector triangles(triangle_qty); 342 | stream.read(reinterpret_cast(triangles.data()), expected_data_size); 343 | 344 | if (stream.gcount() != expected_data_size || stream.fail() || stream.eof()) { 345 | throw std::runtime_error("Failed to read the expected number of triangles. Possible corruption or incomplete file."); 346 | } 347 | 348 | return triangles; 349 | } 350 | 351 | /** 352 | * @brief Check if the given stream contains ASCII STL data. 353 | * 354 | * @tparam Stream The type of the input stream. 355 | * @param stream The input stream to check for ASCII STL data. 356 | * @return True if the stream contains ASCII STL data, false otherwise. 357 | */ 358 | template 359 | inline bool isAscii(Stream& stream) 360 | { 361 | std::string line; 362 | std::getline(stream, line); 363 | bool condition = (line.find("solid") != std::string::npos); 364 | std::getline(stream, line); 365 | condition &= (line.find("facet normal") != std::string::npos); 366 | stream.clear(); 367 | stream.seekg(0); 368 | return condition; 369 | } 370 | 371 | /** 372 | * @brief Deserialize an STL file from a stream and convert it to a vector of triangles. 373 | * 374 | * This function detects the format of the STL file (ASCII or binary) by examining the content 375 | * of the input stream and calls the appropriate deserialization function accordingly. 376 | * 377 | * @tparam Stream The type of the input stream. 378 | * @param stream The input stream from which to read the STL data. 379 | * @return A vector of triangles representing the geometry from the STL file. 380 | */ 381 | template 382 | inline std::vector deserializeStl(Stream& stream) 383 | { 384 | if (isAscii(stream)) { 385 | return deserializeAsciiStl(stream); 386 | } 387 | return deserializeBinaryStl(stream); 388 | } 389 | 390 | //--------------------------------------------------------------------------------------------------------- 391 | // Conversion Utils 392 | //--------------------------------------------------------------------------------------------------------- 393 | using Face = std::array; // v0, v1, v2 394 | 395 | inline bool operator==(const Vec3& rhs, const Vec3& lhs) { 396 | return std::tie(rhs.x, rhs.y, rhs.z) == std::tie(lhs.x, lhs.y, lhs.z); 397 | } 398 | 399 | struct Vec3Hash { 400 | std::size_t operator()(const Vec3& vertex) const { 401 | // Combine hashes of x, y, and z using bitwise XOR 402 | return std::hash{}(vertex.x) ^ std::hash{}(vertex.y) ^ std::hash{}(vertex.z); 403 | } 404 | }; 405 | 406 | /** 407 | * @brief Find the inverse map: vertex -> face idx 408 | * @param triangles The container of triangles from which to find unique vertices 409 | * @return A hash map that maps: for each unique vertex -> a vector of corresponding face indices 410 | */ 411 | template 412 | inline std::unordered_map, Vec3Hash> findInverseMap(const Container& triangles) 413 | { 414 | std::unordered_map, Vec3Hash> map{}; 415 | size_t triangleIdx{0}; 416 | for (const auto& tri : triangles) { 417 | for(const auto vertex : {&tri.v0, &tri.v1, &tri.v2}) 418 | { 419 | auto it = map.find(*vertex); 420 | if (it != std::end(map)) { 421 | it->second.emplace_back(triangleIdx); 422 | continue; 423 | } 424 | map[*vertex] = {triangleIdx}; 425 | } 426 | ++triangleIdx; 427 | } 428 | return map; 429 | } 430 | 431 | 432 | /** 433 | * @brief Finds unique vertices from a vector of triangles 434 | * @param triangles The container of triangles to convert 435 | * @return An tuple containing respectively the vector of vertices and the vector of face indices 436 | */ 437 | template 438 | inline std::tuple, std::vector> 439 | convertToVerticesAndFaces(const Container& triangles) { 440 | const auto& inverseMap = findInverseMap(triangles); 441 | auto verticesNum = inverseMap.size(); 442 | std::vector vertices{}; vertices.reserve(verticesNum); 443 | std::vector faces(triangles.size()); 444 | std::vector vertexPositionInFace(triangles.size(), 0u); 445 | size_t vertexIdx{0}; 446 | for(const auto& item : inverseMap) { 447 | vertices.emplace_back(item.first); 448 | // Multiple faces can have the same vertex index 449 | for(const auto faceIdx : item.second) 450 | faces[faceIdx][vertexPositionInFace[faceIdx]++] = vertexIdx; 451 | ++vertexIdx; 452 | } 453 | return std::make_tuple(std::move(vertices), std::move(faces)); 454 | } 455 | 456 | inline Vec3 operator-(const Vec3& rhs, const Vec3& lhs) { 457 | return {rhs.x - lhs.x, rhs.y - lhs.y, rhs.z - lhs.z}; 458 | } 459 | 460 | inline Vec3 crossProduct(const Vec3& a, const Vec3& b) { 461 | return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; 462 | } 463 | 464 | /** 465 | * @brief Convert vertices and faces to triangles. 466 | * @param vertices The container of vertices. 467 | * @param faces The container of faces. 468 | * @return A vector of triangles constructed from the vertices and faces. 469 | */ 470 | template 471 | inline std::vector convertToTriangles(const ContainerA& vertices, const ContainerB& faces) 472 | { 473 | if (faces.size() == 0) 474 | return {}; 475 | 476 | std::vector triangles; triangles.reserve(faces.size()); 477 | auto getVertex = [&vertices](std::size_t index) { 478 | return std::next(std::begin(vertices), index); 479 | }; 480 | auto minmax = std::max_element(&std::begin(faces)->at(0), &std::begin(faces)->at(0)+faces.size()*3); 481 | 482 | // Check if the minimum and maximum indices are within the bounds of the vector 483 | if (*minmax >= static_cast(vertices.size())) { 484 | throw std::out_of_range("Face index out of range"); 485 | } 486 | 487 | for (const auto& face : faces) { 488 | auto v0 = getVertex(face[0]); 489 | auto v1 = getVertex(face[1]); 490 | auto v2 = getVertex(face[2]); 491 | const auto normal = crossProduct(*v1 - *v0, *v2 - *v0); 492 | triangles.emplace_back(Triangle{normal, *v0, *v1, *v2, 0u}); 493 | } 494 | return triangles; 495 | } 496 | 497 | //--------------------------------------------------------------------------------------------------------- 498 | // Topology Utils 499 | //--------------------------------------------------------------------------------------------------------- 500 | /** 501 | * DisjointSet class to manage disjoint sets with union-find. 502 | */ 503 | class DisjointSet { 504 | std::vector parent; 505 | std::vector rank; 506 | 507 | public: 508 | explicit DisjointSet(size_t size) : parent(size), rank(size, 0) { 509 | for (size_t i = 0; i < size; ++i) parent[i] = i; 510 | } 511 | 512 | size_t find(size_t x) { 513 | if (parent[x] != x) parent[x] = find(parent[x]); 514 | return parent[x]; 515 | } 516 | 517 | void unite(size_t x, size_t y) { 518 | size_t rootX = find(x), rootY = find(y); 519 | if (rootX != rootY) { 520 | if (rank[rootX] < rank[rootY]) parent[rootX] = rootY; 521 | else if (rank[rootX] > rank[rootY]) parent[rootY] = rootX; 522 | else { 523 | parent[rootY] = rootX; 524 | ++rank[rootX]; 525 | } 526 | } 527 | } 528 | 529 | bool connected(size_t x, size_t y) { 530 | return find(x) == find(y); 531 | } 532 | }; 533 | 534 | /** 535 | * Identifies and groups connected components of faces based on shared vertices. 536 | * 537 | * @param vertices A container of vertices. 538 | * @param faces A container of faces, where each face is a collection of vertex indices. 539 | * @return A vector of connected components, where each component is a vector of faces. 540 | */ 541 | template 542 | inline std::vector> 543 | findConnectedComponents(const ContainerA& vertices, const ContainerB& faces) { 544 | DisjointSet ds{vertices.size()}; 545 | for (const auto& tri : faces) { 546 | ds.unite(tri[0], tri[1]); 547 | ds.unite(tri[0], tri[2]); 548 | } 549 | 550 | std::vector> result; 551 | std::unordered_map rootToIndex; 552 | 553 | for (const auto& tri : faces) { 554 | size_t root = ds.find(tri[0]); 555 | if (rootToIndex.find(root) == rootToIndex.end()) { 556 | rootToIndex[root] = result.size(); 557 | result.emplace_back(); 558 | } 559 | result[rootToIndex[root]].push_back(tri); 560 | } 561 | return result; 562 | } 563 | 564 | } //namespace openstl 565 | #endif //OPENSTL_OPENSTL_SERIALIZE_H 566 | --------------------------------------------------------------------------------