diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 0000000..baa3193 --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,121 @@ +name: Build and upload + +on: + pull_request: + push: + tags: + - "*" + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Miniconda (Windows) + if: runner.os == 'Windows' + uses: conda-incubator/setup-miniconda@v3.0.3 + + - name: Install Dependencies (Windows) + if: runner.os == 'Windows' + shell: powershell + run: conda install -c conda-forge mpir -y + + - name: Set env (Windows) + if: runner.os == 'Windows' + run: | + echo "appdata=$env:LOCALAPPDATA" >> ${env:GITHUB_ENV} + echo "GMP_INC=C:\Miniconda\envs\test\Library\include" >> ${env:GITHUB_ENV} + echo "GMP_LIB=C:\Miniconda\envs\test\Library\lib" >> ${env:GITHUB_ENV} + + - name: Build wheels + uses: pypa/cibuildwheel@v2.16.5 + + - name: List generated wheels + run: ls ./wheelhouse/* + + - uses: actions/upload-artifact@v4 + with: + path: ./wheelhouse/*.whl + name: pytetwild-wheel-${{ matrix.os }} + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install \ + libblas-dev \ + libboost-filesystem-dev \ + libboost-system-dev \ + libboost-thread-dev \ + libglu1-mesa-dev \ + libsuitesparse-dev \ + xorg-dev \ + ccache + + - name: Build source distribution + run: | + pipx run build --sdist + + - name: Validate + run: | + pip install twine + twine check dist/* + + - name: Install from dist/ + run: pip install --find-links=dist/ pytetwild[dev] + + - name: Test + run: pytest -vv + + - uses: actions/upload-artifact@v4 + with: + path: dist/*.tar.gz + name: pytetwild-sdist + + release: + name: Release + if: github.event_name == 'push' && contains(github.ref, 'refs/tags') + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pytetwild + permissions: + id-token: write # this permission is mandatory for trusted publishing + steps: + - uses: actions/download-artifact@v4 + - name: Flatten directory structure + run: | + mkdir -p dist/ + find . -name '*.whl' -exec mv {} dist/ \; + find . -name '*.tar.gz' -exec mv {} dist/ \; + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + files: | + ./**/*.whl diff --git a/.gitignore b/.gitignore index e9f2d24..c951985 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ # MISC .mypy_cache +__tracked_surface.stl # Mac .DS_Store @@ -41,3 +42,36 @@ venv/ # VSCode .vscode/ + +# skbuild +_skbuild/ + +# cmake +.ninja_deps +.ninja_log +CMakeCache.txt +CMakeFiles/ +CMakeInit.txt +CPackConfig.cmake +CPackSourceConfig.cmake +build.ninja +cmake_install.cmake +lib/ +ALL_BUILD.vcxproj +ALL_BUILD.vcxproj.filters +INSTALL.vcxproj +INSTALL.vcxproj.filters +PACKAGE.vcxproj +PACKAGE.vcxproj.filters +PyfTetWildWrapper.dir/ +PyfTetWildWrapper.vcxproj +PyfTetWildWrapper.vcxproj.filters +ZERO_CHECK.vcxproj +ZERO_CHECK.vcxproj.filters +fTetWildWrapper.sln +x64/ +Release/ +mpir.dll + +# cibuildwheel +wheelhouse \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 61a6918..9520583 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "src/fTetWild"] path = src/fTetWild url = https://github.com/wildmeshing/fTetWild +[submodule "src/pybind11"] + path = src/pybind11 + url = https://github.com/pybind/pybind11.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..48d4927 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,66 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.11 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + exclude: ^(docs|tests) + - id: ruff-format + +- repo: https://github.com/keewis/blackdoc + rev: v0.3.9 + hooks: + - id: blackdoc + exclude: README.rst + +- repo: https://github.com/numpy/numpydoc + rev: v1.6.0 + hooks: + - id: numpydoc-validation + files: ^src/pytetwild + +- repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: ["--skip=*.vt*"] + +- repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + additional_dependencies: [tomli==2.0.1] + files: ^src/pytetwild/.*\.py + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: trailing-whitespace + +- repo: https://github.com/pre-commit/mirrors-clang-format + rev: v17.0.6 + hooks: + - id: clang-format + files: | + (?x)^( + src/FTetWildWrapper.cpp + )$ + +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v1.7.0 +# hooks: +# - id: mypy +# exclude: ^(docs/|tests) +# additional_dependencies: [ +# "mypy-extensions==1.0.0", +# "toml==0.10.2", +# "types-PyYAML", +# "numpy", +# ] + +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.27.3 + hooks: + - id: check-github-workflows diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..abead66 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.15...3.26) +project(fTetWildWrapper) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ./src/pytetwild) + +if($ENV{USE_MAVX}) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mavx") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -mavx") +endif() + +# Set the path to the fTetWild project +set(fTetWild_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/fTetWild") + +# Add the pybind11 submodule +set(PYBIND11_NEWPYTHON ON) +set(pybind11_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/pybind11") +add_subdirectory(${pybind11_DIR}) + +# Include the fTetWild project as a subdirectory +add_subdirectory(${fTetWild_DIR} EXCLUDE_FROM_ALL) + +pybind11_add_module(PyfTetWildWrapper MODULE "${CMAKE_CURRENT_SOURCE_DIR}/src/FTetWildWrapper.cpp") + +# Include directories from fTetWild required for the wrapper +target_include_directories(PyfTetWildWrapper PUBLIC ${fTetWild_DIR}/src) + +# Link the FloatTetwild library (from fTetWild) +target_link_libraries(PyfTetWildWrapper PRIVATE FloatTetwild) +target_compile_features(PyfTetWildWrapper PUBLIC cxx_std_11) + +if(WIN32) + set_target_properties(PyfTetWildWrapper PROPERTIES SUFFIX ".pyd") + foreach(OUTPUTCONFIG ${CMAKE_CONFIGURATION_TYPES}) + string(TOUPPER ${OUTPUTCONFIG} OUTPUTCONFIG_UPPER) + set_target_properties(PyfTetWildWrapper PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_${OUTPUTCONFIG_UPPER} "${CMAKE_CURRENT_SOURCE_DIR}/src/pytetwild" + LIBRARY_OUTPUT_DIRECTORY_${OUTPUTCONFIG_UPPER} "${CMAKE_CURRENT_SOURCE_DIR}/src/pytetwild" + ) + endforeach() +endif() + +install(TARGETS PyfTetWildWrapper LIBRARY DESTINATION ./pytetwild) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9c006c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to Our Project + +Thank you for considering contributions to our project! Here's how you can help: + +## Setting Up for Development + +We're using [scikit-build-core](https://github.com/scikit-build/scikit-build-core) for our build system and building using ``cmake``. You'll need a handful of system dependencies to build locally. + + +### Linux +On Linux, install the following OS dependencies with: + +``` +sudo apt-get update +sudo apt-get install libblas-dev libboost-filesystem-dev libboost-system-dev libboost-thread-dev libglu1-mesa-dev libsuitesparse-dev xorg-dev ccache -y +``` + +### Windows + +For contributors using Windows, we also have a setup process to ensure you can compile and test changes locally. Here's how to set up your development environment on Windows: + +1. **Install Miniconda**: We use Miniconda to manage dependencies. Install it from [here](https://docs.anaconda.com/free/miniconda/index.html). + +2. **Setup Miniconda and Install Dependencies**: + ``` + conda install -c conda-forge mpir -y + ``` + +3. **Configure Environment Variables**: + Set the required environment variables for the build. This assumes PowerShell. + + ``` + $Env:GMP_INC = "C:\Miniconda\Library\include" + $Env:GMP_LIB = "C:\Miniconda\Library\lib" + ``` + + Note: You may need to adjust these paths depending on if you use a non-global conda environment. + +### Installation +To install the library in editable mode: + + 1. Clone the repository: + ``` + git clone https://github.com/pyvista/pytetwild + ``` + 2. Initialize submodules: + ``` + git submodule update --init --recursive + ``` + 3. Install the project in editable mode and include development dependencies: + ``` + pip install -e .[dev] + ``` + + **Note:** On windows, you'll need to copy the `mpir.dll` to your source directory with: + ``` + python -c "import shutil, os; shutil.copy(os.path.join(os.getenv('GMP_LIB'), '..', 'bin', 'mpir.dll'), './src/pytetwild')" + ``` + +## Code Style and Quality + +- **Documentation**: Follow the `numpydoc` style for docstrings. +- **Linting**: We use `ruff` for code styling to ensure consistency. +- **Pre-commit**: Utilize `pre-commit` hooks to automate checks before commits. Set up your local environment with: + ``` + pip install pre-commit + pre-commit install + ``` +- **Testing**: Write tests for new features or bug fixes and run them using `pytest`. Tests are located in the `tests` directory. + +## How to Contribute + +- **Bugs and Feature Requests**: Submit them through our [issues page](https://github.com/pyvista/pytetwild/issues). +- **Code Contributions**: Make changes in a fork of this repository and submit a pull request (PR) through our [PR page](https://github.com/pyvista/pytetwild/pulls). Follow the standard fork-and-PR workflow for contributions. + +## Community and Conduct + +- We expect all contributors to follow our [Code of Conduct](https://github.com/pyvista/pyvista/blob/main/CODE_OF_CONDUCT.md) to maintain a welcoming and inclusive community. + +## License + +- Contributions are subject to the project's license as found in the [LICENSE.md](./LICENSE.md) file. + +We welcome your contributions and look forward to collaborating with you! diff --git a/LICENSE b/LICENSE index f4bbcd2..fa0086a 100644 --- a/LICENSE +++ b/LICENSE @@ -35,7 +35,7 @@ Mozilla Public License Version 2.0 means any form of the work other than Source Code Form. 1.7. "Larger Work" - means a work that combines Covered Software with other material, in + means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" diff --git a/README.rst b/README.rst index c3d0abe..a21a07f 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,6 @@ pytetwild ######### -**This repository is not ready and the content here is a placeholder.** - |pypi| |MPL| .. |pypi| image:: https://img.shields.io/pypi/v/pytetwild.svg?logo=python&logoColor=white @@ -19,20 +17,17 @@ Python wrapper around the efficient C++ library for tetrahedral meshing provided Installation ************ +We have pre-built wheels for Python 3.8 - Python 3.12 for Windows and Linux x64. + The recommended way to install ``pytetwild`` is via PyPI: .. code:: sh pip install pytetwild -You can also clone the repository and install it from source: - -.. code:: sh - - git clone https://github.com/pyvista/pytetwild.git - cd pytetwild - git submodule update --init --recursive - pip install . +You can also clone the repository and install it from source, but since there's +C++ involved, the build is a bit more complicated. See ``CONTRIBUTING.md`` for +more details. Usage diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..14c93ec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,150 @@ +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "pytetwild" +version = "0.1.dev0" +description = "Python wrapper of fTetWild" +readme = { file = "README.rst", content-type = "text/x-rst" } +authors = [{ name = "Alex Kaszynski", email = "akascap@gmail.com" }] +dependencies = ["numpy"] +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Science/Research', + 'Topic :: Scientific/Engineering :: Information Analysis', + 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: MacOS', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', +] + + +[project.optional-dependencies] +all = ['pyvista'] +dev = ["pytest", "pre-commit", "pyvista", "scipy", "meshio"] + +[tool.scikit-build] +cmake.build-type = "Release" +build-dir = "./build" +editable.mode = "inplace" + +[tool.pytest.ini_options] +testpaths = 'tests' + +[tool.cibuildwheel] +archs = ["auto64"] # 64-bit only +build = "cp38-* cp39-* cp310-* cp311-* cp312-*" # Only build Python 3.8-3.12 wheels +skip = ["pp*", "*musllinux*"] # disable PyPy and musl-based wheels +test-requires = "pytest pyvista scipy meshio" +test-command = "pytest {project}/tests -v" + +[tool.cibuildwheel.linux] +before-all = "yum install gmp gmp-devel -y" +environment = "USE_MAVX='true'" +environment-pass = ["USE_MAVX"] + +# pip install delvewheel && +[tool.cibuildwheel.windows] +before-build = "pip install delvewheel && python -c \"import os; file_path = 'build/CMakeCache.txt'; os.remove(file_path) if os.path.exists(file_path) else None\"" +repair-wheel-command = "python -c \"import shutil, os; shutil.copy(os.path.join(os.getenv('GMP_LIB'), '..', 'bin', 'mpir.dll'), '.')\" && delvewheel repair -w {dest_dir} {wheel} --add-path ." + + +[tool.blackdoc] +# From https://numpydoc.readthedocs.io/en/latest/format.html +# Extended discussion: https://github.com/pyvista/pyvista/pull/4129 +# The length of docstring lines should be kept to 75 characters to facilitate +# reading the docstrings in text terminals. +line-length = 75 + +[tool.pydocstyle] +match = '(?!coverage).*.py' +convention = "numpy" +add-ignore = ["D404"] + +[tool.mypy] +ignore_missing_imports = true +disallow_any_generics = true +pretty = true +show_error_context = true +warn_unused_ignores = true +plugins = ['numpy.typing.mypy_plugin','npt_promote'] + +[tool.numpydoc_validation] +checks = [ + "all", # all but the following: + "GL01", # Contradicts numpydoc examples + "GL02", # Permit a blank line after the end of our docstring + "GL03", # Considering enforcing + "GL06", # Found unknown section + "GL07", # "Sections are in the wrong order. Correct order is: {correct_sections}", + "GL09", # Deprecation warning should precede extended summary (check broken) + "SA01", # Not all docstrings need a see also + "SA04", # See also section does not need descriptions + "SS05", # Appears to be broken. + "ES01", # Not all docstrings need an extend summary. + "EX01", # Examples: Will eventually enforce + "YD01", # Yields: No plan to enforce +] + +[tool.ruff] +exclude = [ + '.git', + '__pycache__', + 'build', + 'dist', +] +line-length = 100 +indent-width = 4 +target-version = 'py38' + +[tool.ruff.lint] +external = ["E131", "D102", "D105"] +ignore = [ + # whitespace before ':' + "E203", + # line break before binary operator + # "W503", + # line length too long + "E501", + # do not assign a lambda expression, use a def + "E731", + # too many leading '#' for block comment + "E266", + # ambiguous variable name + "E741", + # module level import not at top of file + "E402", + # Quotes (temporary) + "Q0", + # bare excepts (temporary) + # "B001", "E722", + "E722", + # we already check black + # "BLK100", + # 'from module import *' used; unable to detect undefined names + "F403", +] +fixable = ["ALL"] +unfixable = [] +extend-select = [ + "B007", + "B010", + "C4", + "F", + "FLY", + "NPY", + "PGH004", + "PIE", + "PT", + "RSE", + "RUF005", + "RUF010", + "RUF100", +] + diff --git a/setup.py b/setup.py deleted file mode 100644 index 3392c47..0000000 --- a/setup.py +++ /dev/null @@ -1 +0,0 @@ -# placeholder for setup.py diff --git a/src/FTetWildWrapper.cpp b/src/FTetWildWrapper.cpp index a4fb9cb..357c25e 100644 --- a/src/FTetWildWrapper.cpp +++ b/src/FTetWildWrapper.cpp @@ -1 +1,258 @@ -// placeholder for FTetWildWrapper.cpp +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +namespace py = pybind11; + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +std::pair>, std::vector>> +extractMeshData(const floatTetWild::Mesh &mesh) { + std::vector> vertices; + std::vector> tetrahedra; + + // Remap tet indices for removed vertices + std::map old_2_new; + int cnt_v = 0; + const auto skip_vertex = [&mesh](const int i) { + return mesh.tet_vertices[i].is_removed; + }; + for (int i = 0; i < mesh.tet_vertices.size(); i++) { + if (!skip_vertex(i)) { + old_2_new[i] = cnt_v; + cnt_v++; + } + } + + // Extract vertices + for (const auto &vertex : mesh.tet_vertices) { + if (!vertex.is_removed) { + vertices.push_back({{static_cast(vertex.pos.x()), + static_cast(vertex.pos.y()), + static_cast(vertex.pos.z())}}); + } + } + + // Extract tetrahedra + for (const auto &tet : mesh.tets) { + if (!tet.is_removed) { + tetrahedra.push_back( + {{old_2_new[tet.indices[0]], old_2_new[tet.indices[1]], + old_2_new[tet.indices[2]], old_2_new[tet.indices[3]]}}); + } + } + + return {vertices, tetrahedra}; +} + +template GEO::vector array_to_geo_vector(py::array_t array) { + auto info = array.request(); + T *ptr = static_cast(info.ptr); + GEO::vector geo_vec(info.size); // Allocate space for info.size elements. + + // Populate the GEO::vector with elements from the array. + for (size_t i = 0; i < info.size; ++i) { + geo_vec.data()[i] = ptr[i]; // Copy each element. + } + + return geo_vec; +} + +std::pair>, std::vector>> +tetrahedralize(GEO::vector &vertices, GEO::vector &faces) { + using namespace floatTetWild; + using namespace Eigen; + + std::cout << "Starting tetrahedralization..." << std::endl; + + // Initialize placeholders for flags and epsr_flags, if needed + std::vector flags; + + // Initialize GEO::Mesh and load the mesh data into it + GEO::Mesh sf_mesh; + std::cout << "Loading mesh..." << std::endl; + + sf_mesh.facets.assign_triangle_mesh(3, vertices, faces, false); + if (sf_mesh.cells.nb() != 0 && sf_mesh.facets.nb() == 0) { + sf_mesh.cells.compute_borders(); + } + sf_mesh.show_stats("I/O"); + + std::cout << "Vertices (Points):" << std::endl; + for (size_t i = 0; i < sf_mesh.vertices.nb(); ++i) { + const GEO::vec3 &p = + sf_mesh.vertices.point(i); // Access the point at index i + std::cout << "Vertex " << i << ": (" << p[0] << ", " << p[1] << ", " << p[2] + << ")" << std::endl; + } + + // Print all facets (faces) + std::cout << "\nFacets (Faces):" << std::endl; + for (size_t i = 0; i < sf_mesh.facets.nb(); ++i) { + std::cout << "Face " << i << ":"; + for (size_t j = 0; j < sf_mesh.facets.nb_vertices(i); ++j) { + // Print each vertex index that makes up the facet + std::cout << " " << sf_mesh.facets.vertex(i, j); + } + std::cout << std::endl; + } + + GEO::mesh_reorder(sf_mesh, GEO::MESH_ORDER_MORTON); // segfault here... + std::cout << "Loaded mesh data into GEO::Mesh." << std::endl; + + // Initialize AABBWrapper with the loaded GEO::Mesh for collision checking + AABBWrapper tree(sf_mesh); + std::cout << "Initialized AABBWrapper." << std::endl; + + // Create an instance of Mesh to hold the output tetrahedral mesh + Mesh mesh; + std::cout << "Created Mesh instance for output." << std::endl; + + // Prepare a vector to track the insertion status of faces + std::vector> input_points; + std::vector> input_faces; + + input_points.resize(sf_mesh.vertices.nb()); + for (size_t i = 0; i < input_points.size(); i++) + input_points[i] << (sf_mesh.vertices.point(i))[0], + (sf_mesh.vertices.point(i))[1], (sf_mesh.vertices.point(i))[2]; + + input_faces.resize(sf_mesh.facets.nb()); + for (size_t i = 0; i < input_faces.size(); i++) + input_faces[i] << sf_mesh.facets.vertex(i, 0), sf_mesh.facets.vertex(i, 1), + sf_mesh.facets.vertex(i, 2); + + Parameters ¶ms = mesh.params; + if (!params.init(tree.get_sf_diag())) { + throw std::runtime_error( + "FTetWildWrapper.cpp: Parameters initialization failed"); + } + const size_t MB = 1024 * 1024; + const size_t stack_size = 64 * MB; + unsigned int max_threads = std::numeric_limits::max(); + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + num_threads = std::min(max_threads, num_threads); + params.num_threads = num_threads; + + std::vector input_tags; + if (input_tags.size() != input_faces.size()) { + input_tags.resize(input_faces.size()); + std::fill(input_tags.begin(), input_tags.end(), 0); + } + bool skip_simplify = false; + simplify(input_points, input_faces, input_tags, tree, params, skip_simplify); + tree.init_b_mesh_and_tree(input_points, input_faces, mesh); + + // Perform tetrahedralization + std::vector is_face_inserted(input_faces.size(), false); + std::cout << "Starting tetrahedralization..." << std::endl; + FloatTetDelaunay::tetrahedralize(input_points, input_faces, tree, mesh, + is_face_inserted); + std::cout << "Tetrahedralization performed." << std::endl; + + insert_triangles(input_points, input_faces, input_tags, mesh, + is_face_inserted, tree, false); + optimization(input_points, input_faces, input_tags, is_face_inserted, mesh, + tree, {{1, 1, 1, 1}}); + correct_tracked_surface_orientation(mesh, tree); + + // filter elements + if (params.smooth_open_boundary) { + smooth_open_boundary(mesh, tree); + for (auto &t : mesh.tets) { + if (t.is_outside) + t.is_removed = true; + } + } else { + if (!params.disable_filtering) { + if (params.use_floodfill) { + filter_outside_floodfill(mesh); + } else if (params.use_input_for_wn) { + filter_outside(mesh, input_points, input_faces); + } else + filter_outside(mesh); + } + } + + std::cout << "Tetrahedralization completed. Extracting mesh data..." + << std::endl; + + return extractMeshData(mesh); +} + +PYBIND11_MODULE(PyfTetWildWrapper, m) { + m.doc() = "Pybind11 plugin for FloatTetWild mesh tetrahedralization"; + + m.def( + "tetrahedralize_mesh", + [](py::array_t vertices, py::array_t faces) { + // GEO::Logger* geo_logger = GEO::Logger::instance(); + // geo_logger-->initialize(); + GEO::initialize(); + + py::print("Starting tetrahedralization process..."); + + // Convert numpy arrays to vectors and call the tetrahedralization + // function + GEO::vector vertices_vec = array_to_geo_vector(vertices); + GEO::vector faces_vec = array_to_geo_vector(faces); + auto result = tetrahedralize(vertices_vec, faces_vec); + auto vertices_result = result.first; + auto tetrahedra_result = result.second; + py::print("Tetrahedralization complete."); + + // Convert results back to numpy arrays + size_t num_vertices = vertices_result.size(); + size_t num_tetrahedra = tetrahedra_result.size(); + py::print("Number of vertices:", num_vertices); + py::print("Number of tetrahedra:", num_tetrahedra); + + // Prepare numpy array (points) + size_t shape[2]{num_vertices, 3}; + auto np_vertices = py::array_t(shape); + auto np_verts_access = np_vertices.mutable_unchecked<2>(); + for (size_t i = 0; i < num_vertices; ++i) { + for (size_t j = 0; j < 3; ++j) { + np_verts_access(i, j) = vertices_result[i][j]; + } + } + py::print("Prepared numpy array for points."); + + // Prepare numpy array (tetrahedra) + size_t shape_tet[2]{num_tetrahedra, 4}; + auto np_tetrahedra = py::array_t(shape_tet); + auto np_tets_access = np_tetrahedra.mutable_unchecked<2>(); + for (size_t i = 0; i < num_tetrahedra; ++i) { + for (size_t j = 0; j < 4; ++j) { + np_tets_access(i, j) = tetrahedra_result[i][j]; + } + } + py::print("Prepared numpy array for tetrahedra."); + + py::print("Tetrahedralization process completed successfully."); + return std::make_pair(np_vertices, np_tetrahedra); + }, + "Tetrahedralizes a mesh given vertices and faces arrays, returning numpy " + "arrays of tetrahedra and points."); +} diff --git a/src/FTetWildWrapper.h b/src/FTetWildWrapper.h deleted file mode 100644 index 51e7b82..0000000 --- a/src/FTetWildWrapper.h +++ /dev/null @@ -1 +0,0 @@ -// placeholder for FTetWildWrapper.h diff --git a/src/_wrapper.pyx b/src/_wrapper.pyx deleted file mode 100644 index a7838ef..0000000 --- a/src/_wrapper.pyx +++ /dev/null @@ -1 +0,0 @@ -# placeholder for ftetwild.pyx diff --git a/src/pybind11 b/src/pybind11 new file mode 160000 index 0000000..8b48ff8 --- /dev/null +++ b/src/pybind11 @@ -0,0 +1 @@ +Subproject commit 8b48ff878c168b51fe5ef7b8c728815b9e1a9857 diff --git a/src/pytetwild/__init__.py b/src/pytetwild/__init__.py new file mode 100644 index 0000000..091ebc6 --- /dev/null +++ b/src/pytetwild/__init__.py @@ -0,0 +1,3 @@ +"""Wrapper for fTetWild.""" +from ._version import __version__ # noqa: F401 +from .pytetwild import tetrahedralize, tetrahedralize_pv # noqa: F401 diff --git a/src/pytetwild/_version.py b/src/pytetwild/_version.py new file mode 100644 index 0000000..0a854c6 --- /dev/null +++ b/src/pytetwild/_version.py @@ -0,0 +1,4 @@ +"""Contains the pytetwild version.""" +from importlib import metadata + +__version__ = metadata.version("pytetwild") diff --git a/src/pytetwild/pytetwild.py b/src/pytetwild/pytetwild.py new file mode 100644 index 0000000..e75a225 --- /dev/null +++ b/src/pytetwild/pytetwild.py @@ -0,0 +1,74 @@ +"""Wrapper for fTetWild.""" +import warnings +import numpy as np +from pytetwild import PyfTetWildWrapper +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + import pyvista as pv + + +def tetrahedralize_pv(mesh: "pv.PolyData") -> "pv.UnstructuredGrid": + """ + Convert a PyVista surface mesh to a PyVista unstructured grid. + + Parameters + ---------- + mesh : pv.PolyData + The input surface mesh. + + Returns + ------- + pv.UnstructuredGrid + The converted unstructured grid. + """ + try: + import pyvista as pv + except: + raise ModuleNotFoundError( + "Install PyVista to use this feature with:\n\n" "pip install pytetwild[all]" + ) + + if not mesh.is_all_triangles: + warnings.warn( + "Input mesh is not all triangles. Either call `.triangulate()`" + " beforehand to suppress this warning or use an all triangle mesh." + ) + mesh = mesh.triangulate() + + vertices = np.array(mesh.points, dtype=np.float64) + faces = np.array(mesh.faces.reshape((-1, 4))[:, 1:4], dtype=np.int32) + + ( + tetrahedral_mesh_vertices, + tetrahedral_mesh_tetrahedra, + ) = PyfTetWildWrapper.tetrahedralize_mesh(vertices, faces) + + cells = np.hstack( + [ + np.full((tetrahedral_mesh_tetrahedra.shape[0], 1), 4, dtype=np.int32), + tetrahedral_mesh_tetrahedra, + ] + ) + cell_types = np.full(tetrahedral_mesh_tetrahedra.shape[0], 10, dtype=np.uint8) + + return pv.UnstructuredGrid(cells, cell_types, tetrahedral_mesh_vertices) + + +def tetrahedralize(vertices: np.ndarray, faces: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + Convert mesh vertices and faces to a tetrahedral mesh. + + Parameters + ---------- + vertices : np.ndarray[double] + The vertices of the mesh. + faces : np.ndarray + The faces of the mesh. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + A tuple containing the vertices and tetrahedra of the tetrahedral mesh. + """ + return PyfTetWildWrapper.tetrahedralize_mesh(vertices, faces) diff --git a/tests/test_data/test_surf.ply b/tests/test_data/test_surf.ply new file mode 100644 index 0000000..8cf7626 Binary files /dev/null and b/tests/test_data/test_surf.ply differ diff --git a/tests/test_data/test_tets.msh b/tests/test_data/test_tets.msh new file mode 100644 index 0000000..9c8a3c8 Binary files /dev/null and b/tests/test_data/test_tets.msh differ diff --git a/tests/test_pytetwild.py b/tests/test_pytetwild.py new file mode 100644 index 0000000..f98c9d7 --- /dev/null +++ b/tests/test_pytetwild.py @@ -0,0 +1,89 @@ +import os +import numpy as np +import pyvista as pv +import vtk +from scipy.spatial import KDTree +import pytest +from pytetwild import ( + tetrahedralize_pv, + tetrahedralize, +) + +THIS_PATH = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture +def default_test_data(): + data = { + "input": pv.read(os.path.join(THIS_PATH, "test_data/test_surf.ply")), + "output": pv.read(os.path.join(THIS_PATH, "test_data/test_tets.msh")), + } + return data + + +# Parameterized test for tetrahedralize_pv function +@pytest.mark.parametrize("mesh_generator", [pv.Icosphere, pv.examples.download_bunny_coarse]) +def test_tetrahedralize_pv(mesh_generator): + mesh = mesh_generator() + result = tetrahedralize_pv(mesh) + assert isinstance( + result, pv.UnstructuredGrid + ), "The result should be a PyVista UnstructuredGrid" + assert result.n_cells > 0, "The resulting mesh should have more than 0 cells" + assert result.n_points > 0, "The resulting mesh should have more than 0 points" + + +# Parameterized test for tetrahedralize function +@pytest.mark.parametrize("mesh_generator", [pv.Icosphere, pv.examples.download_bunny_coarse]) +def test_tetrahedralize(mesh_generator): + mesh = mesh_generator() + vertices = np.array(mesh.points, dtype=np.float64) + faces = np.array(mesh.faces.reshape((-1, 4))[:, 1:4], dtype=np.int32) + + vertices_result, tetrahedra_result = tetrahedralize(vertices, faces) + assert isinstance(vertices_result, np.ndarray), "The vertices result should be a numpy array" + assert isinstance( + tetrahedra_result, np.ndarray + ), "The tetrahedra result should be a numpy array" + assert len(vertices_result) > 0, "There should be more than 0 vertices in the result" + assert len(tetrahedra_result) > 0, "There should be more than 0 tetrahedra in the result" + + +def _sample_points_vtk(mesh_pv, dist_btw_pts=0.01): + point_sampler = vtk.vtkPolyDataPointSampler() + point_sampler.SetInputData(mesh_pv) + point_sampler.SetDistance(dist_btw_pts) + point_sampler.SetPointGenerationMode(point_sampler.REGULAR_GENERATION) + point_sampler.Update() + points_sampled = pv.PolyData(point_sampler.GetOutput()).points + return points_sampled + + +def _symmetric_surf_dist(pts0, pts1): + d_kdtree0, _ = KDTree(pts0).query(pts1) + d_kdtree1, _ = KDTree(pts1).query(pts0) + return (np.mean(d_kdtree0) + np.mean(d_kdtree1)) / 2 + + +@pytest.mark.parametrize( + "mesh_generator", [pv.Icosphere] +) # pv.examples.download_bunny_coarse is not closed, so select_enclosed_points fails +def test_output_points_enclosed(mesh_generator): + input_pv = mesh_generator() + py_output_pv = tetrahedralize_pv(input_pv) + additional_input_scaling = 0.01 + enclosed_pv = py_output_pv.select_enclosed_points(input_pv.scale(1 + additional_input_scaling)) + enclosed_ratio = enclosed_pv.point_data["SelectedPoints"].sum() / input_pv.points.shape[0] + assert ( + enclosed_ratio > 0.99 + ), "all output vertices should be within some threshold of the input surf" + + +def test_default_output_surf_dist(default_test_data): + input_pv = default_test_data["input"] + output_pv = default_test_data["output"] + py_output_pv = tetrahedralize_pv(input_pv) + pts0 = _sample_points_vtk(py_output_pv.extract_surface()) + pts1 = _sample_points_vtk(output_pv.extract_surface()) + surf_dist = _symmetric_surf_dist(pts0, pts1) + assert surf_dist < 1e-2, "surfs of outputs from c++/py should be similar"