diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9a6e68..03f4a41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,13 +14,13 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, windows-latest] - python: ["3.9", "3.10", "3.11", "3.12"] + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.10"] steps: - uses: compas-dev/compas-actions.build@v4 with: invoke_lint: true - check_import: true use_conda: true + check_import: true python: ${{ matrix.python }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 45dfe22..effb848 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,9 +12,10 @@ on: jobs: docs: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - - uses: compas-dev/compas-actions.docs@v3 + - uses: compas-dev/compas-actions.docs@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} use_conda: true + doc_url: https://compas.dev/compas_libigl/ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..fa70e31 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,21 @@ +name: verify-pr-checklist +on: + pull_request: + types: [assigned, opened, synchronize, reopened, labeled, unlabeled] + branches: + - main + - master + +jobs: + build: + name: Check Actions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Changelog check + uses: Zomzog/changelog-checker@v1.2.0 + with: + fileName: CHANGELOG.md + checkNotification: Simple + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1726d1c..0899bb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,96 @@ +name: Release + on: push: tags: - - "v*" - -name: Create Release + - "v*" # Runs only when a version tag (e.g., v1.0.0) is pushed. jobs: - build: + create_release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + build_wheels: + name: Build wheels on ${{ matrix.platform }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [macos-latest, windows-latest] - python: ["3.9", "3.10", "3.11", "3.12"] + include: + - os: ubuntu-latest + platform: manylinux + - os: macos-latest + platform: mac + - os: windows-latest + platform: windows steps: - - uses: compas-dev/compas-actions.build@v4 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install cibuildwheel + run: pipx install cibuildwheel==2.23.1 + + - name: Build wheels + run: cibuildwheel --output-dir wheelhouse . + + - uses: actions/upload-artifact@v4 with: - invoke_lint: true - check_import: true - use_conda: true - python: ${{ matrix.python }} + name: wheels-${{ matrix.platform }} + path: wheelhouse/*.whl - Publish: - needs: build + build_sdist: + name: Build source distribution runs-on: ubuntu-latest steps: - - uses: compas-dev/compas-actions.publish@v2 + - uses: actions/checkout@v4 with: - pypi_token: ${{ secrets.PYPI }} - github_token: ${{ secrets.GITHUB_TOKEN }} - bdist_wheel: false + fetch-depth: 0 + + - name: Build SDist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + + publish: + needs: [build_sdist, build_wheels] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/compas_libigl + permissions: + id-token: write # Required for PyPI trusted publishing + + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wheels-* + path: dist + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: sdist + path: dist + + - name: List files before upload + run: ls -lhR dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 6a36ff7..085acad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,12 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions -# *.so - -# Distribution / packaging .Python env/ build/ develop-eggs/ dist/ -# downloads/ eggs/ .eggs/ lib/ @@ -20,22 +14,28 @@ lib64/ parts/ sdist/ var/ -# wheels/ *.egg-info/ .installed.cfg *.egg +.pytest_cache +.ruff_cache -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Build and packaging +_skbuild/ +_build/ *.manifest *.spec -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.spyderproject +.spyproject +.ropeproject -# Unit test / coverage reports +# Testing and coverage htmlcov/ .tox/ .coverage @@ -46,88 +46,43 @@ coverage.xml *.cover .hypothesis/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation +# Documentation docs/_build/ +/site -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv +# Environment .env - -# virtualenv .venv venv/ ENV/ +.python-version -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -# ------------------------------------------------------------------------------ -# compas_libigl -# ------------------------------------------------------------------------------ - -*.3dmbak -*.rhl +# Jupyter +.ipynb_checkpoints -temp +# Logs and temp files +*.log +pip-log.txt +pip-delete-this-directory.txt +temp/ +local_settings.py +# External dependencies +external/ ext/** !ext/PLACEHOLDER +# Recipe recipe/** !recipe/sha256.py -build/** - -.DS_Store - -.vscode - -*.so - +# COMPAS specific +*.3dmbak +*.rhl src/compas-viewers - generated - *.gz -.pytest_cache -.ruff_cache +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 809c47f..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "ext/libigl"] - path = ext/libigl - url = https://github.com/libigl/libigl.git diff --git a/CHANGELOG.md b/CHANGELOG.md index acc328b..fed6040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [0.3.1] 2025-04-01 + +### Added + +### Changed + +* Pybind11 build system was updated to Nanobind. +* Github actions workflow was updated for pypi support. +* Documentation was updated to build without errors. + +### Removed + ## [0.3.1] 2023-12-05 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f6f652..a25219b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,107 +1,174 @@ -cmake_minimum_required(VERSION 3.14) -project(compas_libigl) - -set(CMAKE_CXX_STANDARD 11) -set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR}/cmake) - -# MODULE library output directory -# if(WIN32) -# set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl) -# else() -# set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl) -# endif() - -# Eigen -if(WIN32) - set(EIGEN3_INCLUDE_DIR "$ENV{CONDA_PREFIX}/Library/include/eigen3") -else() - set(EIGEN3_INCLUDE_DIR "$ENV{CONDA_PREFIX}/include/eigen3") -endif() - -# Boost -if(WIN32) - set(BOOST_ROOT "$ENV{CONDA_PREFIX}/Library/include") -else() - set(BOOST_ROOT "$ENV{CONDA_PREFIX}/include") -endif() - -# Pybind11 -set(PYBIND11_CPP_STANDARD -std=c++11) -find_package(pybind11 CONFIG REQUIRED) - -# Libigl -option(LIBIGL_WITH_TRIANGLE "Use Triangle" OFF) -option(LIBIGL_WITH_CGAL "Use CGAL" OFF) -find_package(LIBIGL REQUIRED) - -# geodistance -pybind11_add_module(compas_libigl_geodistance MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/geodistance/geodistance.cpp) - -# set_target_properties(compas_libigl_geodistance -# PROPERTIES -# RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/geodistance/ -# LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/geodistance/ -# ) -target_link_libraries(compas_libigl_geodistance PRIVATE igl::core) - -# isolines -pybind11_add_module(compas_libigl_isolines MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/isolines/isolines.cpp) - -# set_target_properties(compas_libigl_isolines -# PROPERTIES -# RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/isolines/ -# LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/isolines/ -# ) -target_link_libraries(compas_libigl_isolines PRIVATE igl::core) - -# planarize -pybind11_add_module(compas_libigl_planarize MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/planarize/planarize.cpp) +cmake_minimum_required(VERSION 3.15...3.26) -# set_target_properties(compas_libigl_planarize -# PROPERTIES -# RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/planarize/ -# LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/planarize/ -# ) -target_link_libraries(compas_libigl_planarize PRIVATE igl::core) +project(compas_libigl LANGUAGES CXX) -# massmatrix -pybind11_add_module(compas_libigl_massmatrix MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/massmatrix/massmatrix.cpp) -target_link_libraries(compas_libigl_massmatrix PRIVATE igl::core) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_BUILD_TYPE Release) -# set_target_properties(compas_libigl_massmatrix PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/massmatrix/) -# set_target_properties(compas_libigl_massmatrix PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/massmatrix/) +# ===================================================================== +# Set this flag to ON for developing to reduce build time. +# Set this flag to OFF for publishing for file size reduction. +# ===================================================================== +option(ENABLE_PRECOMPILED_HEADERS "Enable precompiled headers for the build" ON) -# curvature -pybind11_add_module(compas_libigl_curvature MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/curvature/curvature.cpp) -target_link_libraries(compas_libigl_curvature PRIVATE igl::core) +# ===================================================================== +# Set maximum heap size for MSVC +# ===================================================================== -# set_target_properties(compas_libigl_curvature PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/curvature/) -# set_target_properties(compas_libigl_curvature PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/curvature/) - -# intersections -pybind11_add_module(compas_libigl_intersections MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/intersections/intersections.cpp) -target_link_libraries(compas_libigl_intersections PRIVATE igl::core) +if(MSVC) + set(CMAKE_GENERATOR_PLATFORM x64) + add_compile_options(/Zm1200) +endif() -# set_target_properties(compas_libigl_intersections PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/intersections/) -# set_target_properties(compas_libigl_intersections PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/intersections/) +# ===================================================================== +# Build size reduction. +# ===================================================================== + +if (NOT ENABLE_PRECOMPILED_HEADERS) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) + if(MSVC) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /O1") # Optimize for size on MSVC + else() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Os") # Optimize for size on GCC/Clang + endif() +endif() -# boundaries -pybind11_add_module(compas_libigl_boundaries MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/boundaries/boundaries.cpp) -target_link_libraries(compas_libigl_boundaries PRIVATE igl::core) +# ===================================================================== +# Dependencies +# ===================================================================== +include(ExternalProject) + +# Define source directories for external dependencies +set(EXTERNAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external") +set(EIGEN_SOURCE_DIR "${EXTERNAL_DIR}/eigen") +set(LIBIGL_SOURCE_DIR "${EXTERNAL_DIR}/libigl") + +# Create directories if they don't exist +file(MAKE_DIRECTORY ${EXTERNAL_DIR}) +file(MAKE_DIRECTORY ${EIGEN_SOURCE_DIR}) +file(MAKE_DIRECTORY ${LIBIGL_SOURCE_DIR}) + +# Download Eigen first +if(NOT EXISTS "${EIGEN_SOURCE_DIR}/Eigen") + message(STATUS "Downloading Eigen...") + ExternalProject_Add( + eigen_download + PREFIX ${EXTERNAL_DIR} + URL https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz + SOURCE_DIR "${EIGEN_SOURCE_DIR}" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + LOG_DOWNLOAD ON + UPDATE_COMMAND "" + PATCH_COMMAND "" + ) +endif() -# set_target_properties(compas_libigl_boundaries PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/boundaries/) -# set_target_properties(compas_libigl_boundaries PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/boundaries/) +# Download libigl after Eigen +if(NOT EXISTS "${LIBIGL_SOURCE_DIR}/include/igl") + message(STATUS "Downloading libigl...") + ExternalProject_Add( + libigl_download + DEPENDS eigen_download + PREFIX ${EXTERNAL_DIR} + URL https://github.com/libigl/libigl/archive/refs/heads/main.zip + SOURCE_DIR "${LIBIGL_SOURCE_DIR}" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + LOG_DOWNLOAD ON + UPDATE_COMMAND "" + PATCH_COMMAND "" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) +endif() -# parametrisations -pybind11_add_module(compas_libigl_parametrisation MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/parametrisation/parametrisation.cpp) -target_link_libraries(compas_libigl_parametrisation PRIVATE igl::core) +# Create a custom target for all external dependencies +add_custom_target(external_downloads ALL) +if(TARGET eigen_download) + add_dependencies(external_downloads eigen_download) +endif() +if(TARGET libigl_download) + add_dependencies(external_downloads libigl_download) +endif() -# set_target_properties(compas_libigl_parametrisation PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/parametrisation/) -# set_target_properties(compas_libigl_parametrisation PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/parametrisation/) +# Add include directories for external dependencies +set(EIGEN_INCLUDE_DIR "${EIGEN_SOURCE_DIR}") +set(LIBIGL_INCLUDE_DIR "${LIBIGL_SOURCE_DIR}/include") + +if (NOT SKBUILD) + message(WARNING "\ + This CMake file is meant to be executed using 'scikit-build'. Running + it directly will almost certainly not produce the desired result. If + you are a user trying to install this package, please use the command + below, which will install all necessary build dependencies, compile + the package in an isolated environment, and then install it. + ===================================================================== + $ pip install . + ===================================================================== + If you are a software developer, and this is your own package, then + it is usually much more efficient to install the build dependencies + in your environment once and use the following command that avoids + a costly creation of a new virtual environment at every compilation: + ===================================================================== + $ pip install nanobind scikit-build-core[pyproject] + $ pip install --no-build-isolation -ve . + ===================================================================== + You may optionally add -Ceditable.rebuild=true to auto-rebuild when + the package is imported. Otherwise, you need to re-run the above + after editing C++ files.") +endif() -# meshing -pybind11_add_module(compas_libigl_meshing MODULE ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/meshing/meshing.cpp) -target_link_libraries(compas_libigl_meshing PRIVATE igl::core) +# Find Python and nanobind +find_package(Python 3.8 + REQUIRED COMPONENTS Interpreter Development.Module + OPTIONAL_COMPONENTS Development.SABIModule) +find_package(nanobind CONFIG REQUIRED) +find_package(Threads REQUIRED) + +# Create a shared precompiled header library +if (ENABLE_PRECOMPILED_HEADERS) + add_library(compas_pch INTERFACE) + target_precompile_headers(compas_pch INTERFACE src/compas.hpp) + target_include_directories(compas_pch INTERFACE + ${EIGEN_INCLUDE_DIR} + ${LIBIGL_INCLUDE_DIR} + ) +endif() -# set_target_properties(compas_libigl_meshing PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/meshing/) -# set_target_properties(compas_libigl_meshing PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/compas_libigl/meshing/) +# Function to add a nanobind module with include directories +function(add_nanobind_module module_name source_file) + nanobind_add_module(${module_name} STABLE_ABI NB_STATIC ${source_file}) + + # Ensure external dependencies are downloaded first + add_dependencies(${module_name} external_downloads) + + # Add include directories and link PCH if enabled + if (ENABLE_PRECOMPILED_HEADERS) + target_link_libraries(${module_name} PRIVATE compas_pch) + else() + target_include_directories(${module_name} SYSTEM PRIVATE + ${EIGEN_INCLUDE_DIR} + ${LIBIGL_INCLUDE_DIR} + ) + endif() + + target_link_libraries(${module_name} PRIVATE Threads::Threads) + install(TARGETS ${module_name} LIBRARY DESTINATION compas_libigl) +endfunction() + +# Add modules +add_nanobind_module(_nanobind src/nanobind.cpp) +add_nanobind_module(_types_std src/types_std.cpp) +add_nanobind_module(_boundaries src/boundaries.cpp) +add_nanobind_module(_curvature src/curvature.cpp) +add_nanobind_module(_geodistance src/geodistance.cpp) +add_nanobind_module(_intersections src/intersections.cpp) +add_nanobind_module(_isolines src/isolines.cpp) +add_nanobind_module(_massmatrix src/massmatrix.cpp) +add_nanobind_module(_meshing src/meshing.cpp) +add_nanobind_module(_parametrisation src/parametrisation.cpp) +add_nanobind_module(_planarize src/planarize.cpp) \ No newline at end of file diff --git a/README.md b/README.md index 1d37cef..58da428 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,9 @@ conda create -n igl compas_libigl A dev version of `compas_libigl` can be installed using a combination of conda and pip. ```bash -conda create -n igl-dev python=3.9 git cmake">=3.14" boost eigen=3.3 pybind11 --yes +conda create -n igl-dev python=3.9 --yes conda activate igl -git clone --recursive https://github.com/BlockResearchGroup/compas_libigl.git -cd compas_libigl -rm -rf build -pip install -e . +pip install --no-build-isolation -ve . ``` ## Libigl functions diff --git a/cmake/FindLIBIGL.cmake b/cmake/FindLIBIGL.cmake deleted file mode 100644 index 69d9a61..0000000 --- a/cmake/FindLIBIGL.cmake +++ /dev/null @@ -1,38 +0,0 @@ -# - Try to find the LIBIGL library -# Once done this will define -# -# LIBIGL_FOUND - system has LIBIGL -# LIBIGL_INCLUDE_DIR - **the** LIBIGL include directory -if(LIBIGL_FOUND) - return() -endif() - -find_path(LIBIGL_INCLUDE_DIR igl/readOBJ.h - HINTS - ENV LIBIGL - ENV LIBIGLROOT - ENV LIBIGL_ROOT - ENV LIBIGL_DIR - PATHS - ${CMAKE_SOURCE_DIR}/ext/libigl - ${CMAKE_SOURCE_DIR}/../.. - ${CMAKE_SOURCE_DIR}/.. - ${CMAKE_SOURCE_DIR} - ${CMAKE_SOURCE_DIR}/libigl - ${CMAKE_SOURCE_DIR}/../libigl - ${CMAKE_SOURCE_DIR}/../../libigl - ${CMAKE_SOURCE_DIR}/../ext/libigl - /usr - /usr/local - /usr/local/igl/libigl - PATH_SUFFIXES include -) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(LIBIGL - "\nlibigl not found --- You can download it using:\n\tgit clone --recursive https://github.com/libigl/libigl.git ${CMAKE_SOURCE_DIR}/../libigl" - LIBIGL_INCLUDE_DIR) -mark_as_advanced(LIBIGL_INCLUDE_DIR) - -list(APPEND CMAKE_MODULE_PATH "${LIBIGL_INCLUDE_DIR}/../cmake") -include(libigl) diff --git a/data/PLACEHOLDER b/data/PLACEHOLDER deleted file mode 100644 index f73a8b1..0000000 --- a/data/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -# placeholder for the data folder. diff --git a/docs/PLACEHOLDER b/docs/PLACEHOLDER deleted file mode 100644 index f9768c1..0000000 --- a/docs/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -# container for the source files of the documentation diff --git a/docs/_images/PLACEHOLDER b/docs/_images/PLACEHOLDER deleted file mode 100644 index 48f73eb..0000000 --- a/docs/_images/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -# container for images to be included in the docs diff --git a/docs/_images/boundaries.png b/docs/_images/boundaries.png deleted file mode 100644 index a130bb0..0000000 Binary files a/docs/_images/boundaries.png and /dev/null differ diff --git a/docs/_images/curvature.png b/docs/_images/curvature.png deleted file mode 100644 index 21ab9a7..0000000 Binary files a/docs/_images/curvature.png and /dev/null differ diff --git a/docs/_images/example_boundaries.png b/docs/_images/example_boundaries.png new file mode 100644 index 0000000..bcd29aa Binary files /dev/null and b/docs/_images/example_boundaries.png differ diff --git a/docs/_images/example_curvature_gaussian.png b/docs/_images/example_curvature_gaussian.png new file mode 100644 index 0000000..a6e84b5 Binary files /dev/null and b/docs/_images/example_curvature_gaussian.png differ diff --git a/docs/_images/example_curvature_principal.png b/docs/_images/example_curvature_principal.png new file mode 100644 index 0000000..1d3c97c Binary files /dev/null and b/docs/_images/example_curvature_principal.png differ diff --git a/docs/_images/example_geodistance.png b/docs/_images/example_geodistance.png new file mode 100644 index 0000000..cdfe616 Binary files /dev/null and b/docs/_images/example_geodistance.png differ diff --git a/docs/_images/example_geodistance_multiple.png b/docs/_images/example_geodistance_multiple.png new file mode 100644 index 0000000..129ce24 Binary files /dev/null and b/docs/_images/example_geodistance_multiple.png differ diff --git a/docs/_images/example_intersections.png b/docs/_images/example_intersections.png new file mode 100644 index 0000000..ef131e2 Binary files /dev/null and b/docs/_images/example_intersections.png differ diff --git a/docs/_images/example_isolines.png b/docs/_images/example_isolines.png new file mode 100644 index 0000000..75963b9 Binary files /dev/null and b/docs/_images/example_isolines.png differ diff --git a/docs/_images/example_massmatrix.png b/docs/_images/example_massmatrix.png new file mode 100644 index 0000000..291eaa9 Binary files /dev/null and b/docs/_images/example_massmatrix.png differ diff --git a/docs/_images/example_meshing_geodesic.png b/docs/_images/example_meshing_geodesic.png new file mode 100644 index 0000000..1be2ed6 Binary files /dev/null and b/docs/_images/example_meshing_geodesic.png differ diff --git a/docs/_images/example_meshing_plane.png b/docs/_images/example_meshing_plane.png new file mode 100644 index 0000000..f88e048 Binary files /dev/null and b/docs/_images/example_meshing_plane.png differ diff --git a/docs/_images/example_meshing_planes.png b/docs/_images/example_meshing_planes.png new file mode 100644 index 0000000..025dd9c Binary files /dev/null and b/docs/_images/example_meshing_planes.png differ diff --git a/docs/_images/example_meshing_waves.png b/docs/_images/example_meshing_waves.png new file mode 100644 index 0000000..1b9035c Binary files /dev/null and b/docs/_images/example_meshing_waves.png differ diff --git a/docs/_images/example_parametrisation.png b/docs/_images/example_parametrisation.png new file mode 100644 index 0000000..42c36f8 Binary files /dev/null and b/docs/_images/example_parametrisation.png differ diff --git a/docs/_images/example_planarize.png b/docs/_images/example_planarize.png new file mode 100644 index 0000000..2d56c15 Binary files /dev/null and b/docs/_images/example_planarize.png differ diff --git a/docs/_images/geodistance.png b/docs/_images/geodistance.png deleted file mode 100644 index 6c09a2a..0000000 Binary files a/docs/_images/geodistance.png and /dev/null differ diff --git a/docs/_images/intersections.png b/docs/_images/intersections.png deleted file mode 100644 index 166b359..0000000 Binary files a/docs/_images/intersections.png and /dev/null differ diff --git a/docs/_images/isolines.png b/docs/_images/isolines.png deleted file mode 100644 index b116ea4..0000000 Binary files a/docs/_images/isolines.png and /dev/null differ diff --git a/docs/_images/massmatrix.png b/docs/_images/massmatrix.png deleted file mode 100644 index b9a83c0..0000000 Binary files a/docs/_images/massmatrix.png and /dev/null differ diff --git a/docs/_images/parametrisation.png b/docs/_images/parametrisation.png deleted file mode 100644 index 660abb4..0000000 Binary files a/docs/_images/parametrisation.png and /dev/null differ diff --git a/docs/_images/planarize.png b/docs/_images/planarize.png deleted file mode 100644 index 0a381f2..0000000 Binary files a/docs/_images/planarize.png and /dev/null differ diff --git a/docs/_images/windows_frame.png b/docs/_images/windows_frame.png new file mode 100644 index 0000000..c8e0768 Binary files /dev/null and b/docs/_images/windows_frame.png differ diff --git a/docs/_static/PLACEHOLDER b/docs/_static/PLACEHOLDER deleted file mode 100644 index f611256..0000000 --- a/docs/_static/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -# container for static files, e.g. logo, banner images, javascript, stylesheets, ... diff --git a/docs/acknowledgements.rst b/docs/acknowledgements.rst index 5e7fc39..5a0071f 100644 --- a/docs/acknowledgements.rst +++ b/docs/acknowledgements.rst @@ -2,4 +2,14 @@ Acknowledgements ******************************************************************************** -Coming soon! +COMPAS libigl +============= + +This package provides Python bindings for selected functionality of libigl, +a simple C++ geometry processing library. libigl is: + +* Copyright (C) 2020 Alec Jacobson +* Copyright (C) 2020 Daniele Panozzo +* And others + +The original libigl library is available at: https://github.com/libigl/libigl \ No newline at end of file diff --git a/docs/api/compas_libigl.rst b/docs/api/compas_libigl.rst index a8bdef1..8bf3953 100644 --- a/docs/api/compas_libigl.rst +++ b/docs/api/compas_libigl.rst @@ -11,14 +11,22 @@ Functions :toctree: generated/ :nosignatures: + add + get + get_beetle + get_armadillo intersection_ray_mesh + intersection_rays_mesh trimesh_boundaries trimesh_gaussian_curvature trimesh_principal_curvature trimesh_geodistance + trimesh_geodistance_multiple trimesh_isolines + groupsort_isolines trimesh_massmatrix trimesh_harmonic trimesh_lscm - trimesh_remesh_along_isoline quadmesh_planarize + trimesh_remesh_along_isoline + trimesh_remesh_along_isolines diff --git a/docs/devguide.rst b/docs/devguide.rst new file mode 100644 index 0000000..931ad1a --- /dev/null +++ b/docs/devguide.rst @@ -0,0 +1,14 @@ +******************************************************************************** +Development Guide +******************************************************************************** + +.. toctree:: + :maxdepth: 1 + + devguide/overview + devguide/compiler + devguide/conda_environment + devguide/contribute + devguide/cmake_configuration + devguide/types + devguide/style diff --git a/docs/devguide/cmake_configuration.rst b/docs/devguide/cmake_configuration.rst new file mode 100644 index 0000000..241d91f --- /dev/null +++ b/docs/devguide/cmake_configuration.rst @@ -0,0 +1,120 @@ +******************************************************************************** +CMake Configuration +******************************************************************************** + +This project uses CMake with scikit-build-core and nanobind for building Python extensions. + +Core Settings +============= + +.. code-block:: cmake + + set(CMAKE_CXX_STANDARD 20) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + +External Dependencies +===================== + +We use CMake's ExternalProject to manage external dependencies (libigl, Eigen) as header-only libraries. This approach: + +1. Downloads dependencies at configure time +2. Extracts them to the ``external`` directory +3. Sets them up as header-only libraries +4. Requires no system-wide installation + +Configuration +------------- + +.. code-block:: cmake + + # Define source directories + set(EXTERNAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external") + set(LIBIGL_SOURCE_DIR "${EXTERNAL_DIR}/libigl") + set(EIGEN_SOURCE_DIR "${EXTERNAL_DIR}/eigen") + + # Create target for all downloads + add_custom_target(external_downloads ALL) + +Example: libigl Setup +--------------------- + +.. code-block:: cmake + + if(NOT EXISTS "${LIBIGL_SOURCE_DIR}") + message(STATUS "Downloading libigl...") + ExternalProject_Add( + libigl_download + GIT_REPOSITORY https://github.com/libigl/libigl.git + GIT_TAG v2.5.0 + SOURCE_DIR "${LIBIGL_SOURCE_DIR}" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + LOG_DOWNLOAD ON + ) + add_dependencies(external_downloads libigl_download) + endif() + +Key Components +--------------- + +* ``SOURCE_DIR``: Where to extract the downloaded files +* Empty ``CONFIGURE_COMMAND``, ``BUILD_COMMAND``, ``INSTALL_COMMAND``: Treat as header-only +* ``LOG_DOWNLOAD ON``: Enable download progress logging +* ``add_dependencies``: Ensure downloads complete before building + +Include Directories +------------------- + +After download, headers are made available through: + +.. code-block:: cmake + + include_directories( + ${LIBIGL_SOURCE_DIR}/include + ${EIGEN_SOURCE_DIR} + ) + +Build Flags +----------- + +Dependencies are configured with specific compiler flags: + +.. code-block:: cmake + + # Platform-specific flags + if(MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + add_definitions(-DNOMINMAX) # Prevent Windows max/min macro conflicts + endif() + +This setup ensures: +* No compilation of external libraries needed +* Consistent headers across different platforms +* Simplified dependency management +* Reproducible builds +* Proper handling of Windows-specific issues + +Precompiled Headers +------------------- + +We use precompiled headers to improve build times. The configuration is optimized for template-heavy code: + +.. code-block:: cmake + + # Enhanced PCH configuration + set(CMAKE_PCH_INSTANTIATE_TEMPLATES ON) # Improve template compilation + set(CMAKE_PCH_WARN_INVALID ON) # Warn about invalid PCH usage + + # Configure PCH for the extension + target_precompile_headers(compas_libigl_ext + PRIVATE + src/compas.h + ) + +Note: When adding new headers that are frequently included, consider adding them to the precompiled header ``src/compas.h`` to further improve build times. Common headers to precompile: + +* STL containers (vector, string) +* libigl core headers +* Eigen matrix types +* On Windows, ensure NOMINMAX is defined before any Windows headers diff --git a/docs/devguide/compiler.rst b/docs/devguide/compiler.rst new file mode 100644 index 0000000..71d8532 --- /dev/null +++ b/docs/devguide/compiler.rst @@ -0,0 +1,46 @@ +******************************************************************************** +Compiler Requirements +******************************************************************************** + +Before installing COMPAS libigl, you need to ensure you have the appropriate C++ compiler setup for your operating system: + +Windows +------- + +* Install Visual Studio Build Tools (2022 or newer) +* During installation, select "Desktop development with C++" +* No additional PATH settings are required as CMake will automatically find the compiler +* Note: Due to Windows max/min macro conflicts, we use #define NOMINMAX before including Windows headers + +macOS +----- + +* Install Xcode Command Line Tools: + + .. code-block:: bash + + xcode-select --install + +* The clang compiler will be automatically available after installation + +Linux +----- + +* Install GCC and related build tools. On Ubuntu/Debian: + + .. code-block:: bash + + sudo apt-get update + sudo apt-get install build-essential + +* On RHEL/Fedora: + + .. code-block:: bash + + sudo dnf groupinstall "Development Tools" + +* Alternatively, when using conda, you can install the C++ compiler through conda: + + .. code-block:: bash + + conda install gxx_linux-64 diff --git a/docs/devguide/conda_environment.rst b/docs/devguide/conda_environment.rst new file mode 100644 index 0000000..d490b8a --- /dev/null +++ b/docs/devguide/conda_environment.rst @@ -0,0 +1,24 @@ +******************************************************************************** +Conda Environment +******************************************************************************** + +There are two ways to set up the development environment: + +Using environment.yml (recommended) +----------------------------------- + +.. code-block:: bash + + conda env create -f environment.yml + conda activate compas_libigl + +Manual setup +------------ + +.. code-block:: bash + + conda create -n compas_libigl python=3.9 compas -c conda-forge cmake -y + pip install -r requirements-dev.txt + pip install --no-build-isolation -ve . -Ceditable.rebuild=true + +Both methods will create and configure the same development environment. diff --git a/docs/devguide/contribute.rst b/docs/devguide/contribute.rst new file mode 100644 index 0000000..ef79c2c --- /dev/null +++ b/docs/devguide/contribute.rst @@ -0,0 +1,191 @@ +******************************************************************************** +Contribute +******************************************************************************** + +Getting Started +=============== + +Fork the repository to your GitHub account and clone it: + +.. code-block:: bash + + git clone https://github.com//compas_libigl.git + cd compas_libigl + +Create and switch to a development branch: + +.. code-block:: bash + + git branch + git checkout + + +Binding Process +=============== + +C++ Binding +----------- + +Create new files in the ``src`` folder: + +.. code-block:: bash + + cd src + touch new_module.cpp + touch new_module.h + +Define new methods declarations in ``src/new_module.h``: + +.. code-block:: cpp + + #pragma once + + #include "compas.h" + #include // Include relevant libigl header + + // Your method declarations here + +Implement the functions and add the nanobind module registration: ``src/new_module.cpp``, we name modules started with ``_``: + +.. code-block:: cpp + + #include "new_module" + #include // For numeric_limits + + #ifdef _WIN32 + #define NOMINMAX // Prevent Windows max/min macro conflicts + #endif + + // Your method definitions here + + NB_MODULE(_new_module, m) { + m.def( + "python_function_name", + &cpp_function_name, + "description", + "my_argument1"_a, + "my_argument2"_a, + ); + } + +Rebuild the project with: + +.. code-block:: bash + + pip install --no-build-isolation -ve . -Ceditable.rebuild=true + + +CMake +----- + +Add the new module to the CMakeLists.txt file: + +.. code-block:: cmake + + add_nanobind_module(_new_module src/new_module.cpp) + +.. note:: + - We build small dynamic libraries for each module to avoid large monolithic libraries for two reasons: build time and file size. + - If your package requires C++ standard library data types (e.g., vector, array, map, etc.), bind them in the `types_std.cpp` file. + - Do not bind C++ types with the same names, as this will result in errors even if they are in different namespaces and libraries. + - On Windows, remember to use #define NOMINMAX before including Windows headers to prevent max/min macro conflicts. + + +Python Binding +-------------- + +Add the new python submodule in ``src/compas_libigl/__init__.py``: + +.. code-block:: python + + __all_plugins__ = [ + ... + "compas_libigl.new_module", + ] + +Implement the submodule in ``src/compas_libigl/new_module.py``: + +.. code-block:: python + + from compas_libigl._nanobind import new_module + + def new_function(): + ... + result_from_cplusplus = new_module.python_function_name() + ... + + +After creating new source files, you must rebuild the project with: + +.. code-block:: bash + + pip install --no-build-isolation -ve . + + +Document, Test, and Format +========================== + +Documentation +------------- + +Document your scripts with a screenshot in ``docs/examples``. Documentation can be build with: + +.. code-block:: bash + + invoke docs + + +Scripts should be profiled for performance checks: + +.. code-block:: bash + + pip install line_profiler + kernprof -l -v -r + +Add a description of the changes in ``CHANGELOG.md``. + +.. code-block:: markdown + + ## [0.3.2] 2025-04-03 + + ### Added + + * New libigl function bindings. + + ### Changed + + ### Removed + +Testing +------- + +Write tests in the ``tests`` folder and run with pytest. As a bare minimum add a simplest possible test, this will help you run all the tests to know if everything is working. + +.. code-block:: bash + + invoke test + + +Formatting +---------- + +Run the formatter to ensure consistent code style: + +.. code-block:: bash + + invoke format + invoke lint + + +GitHub Pull Request +=================== + +Push the changes to your forked repository: + +.. code-block:: bash + + git add --all + git commit -m "commit message" + git push origin + +Afterwards there should be a green button on GitHub to open a pull request. Check if all the GitHub tasks run successfully. Lastly, as for a review of your code, assign a reviewer at the top left corner of the pull request and wait for the review and make the necessary changes. One of the reviewers will merge your pull request. diff --git a/docs/devguide/overview.rst b/docs/devguide/overview.rst new file mode 100644 index 0000000..4a2c573 --- /dev/null +++ b/docs/devguide/overview.rst @@ -0,0 +1,46 @@ +******************************************************************************** +Overview +******************************************************************************** + +`libigl `_ integration uses `nanobind `_ for Python bindings for COMPAS. The contribution guide is found in the "Contribute" section, which explains how to create a pull-request and implement new methods in both C++ and Python. Additional sections explain technical aspects of the repository that are useful during the binding process. + +File and Folder Structure +------------------------- + +The compas_libigl package contains numerous files and folders. For a simpler reference, check out the minimal `nanobind binding example `_. Comparing it with compas_libigl helps understand the additional features provided by the COMPAS framework. For basic libigl bindings, you'll mainly work with the ``src`` folder, ``tests``, and ``examples``. The ``CMakeLists.txt`` is pre-configured to automatically include any new C++ source files, so you typically won't need to modify it. + +Source Code +^^^^^^^^^^^ +* ``src/`` - C++ backend code +* ``src/compas_libigl/`` - Python frontend code + +Build & Dependencies +^^^^^^^^^^^^^^^^^^^^ +* ``build/`` - Distributables e.g. for PyPi package +* ``external/`` - External C++ dependencies, downloaded via CMake ExternalProject module +* ``CMakeLists.txt`` - C++ project configuration +* ``pyproject.toml`` - Python project configuration (pip install -e .) +* ``requirements.txt`` - Runtime requirements (pip install -r requirements.txt) +* ``requirements-dev.txt`` - Development requirement (pip install -r requirements-dev.txt) +* ``tasks.py`` - Development tasks (invoke test, invoke docs, invoke format, invoke lint) + +Tests & Examples +^^^^^^^^^^^^^^^^ +* ``examples/`` - Example files +* ``tests/`` - Test files + +Documentation +^^^^^^^^^^^^^ +* ``docs/`` - Source code documentation +* ``dist/`` - Documentation build output + +Data & Temporary Files +^^^^^^^^^^^^^^^^^^^^^^ +* ``data/`` - Data sets (.obj, .off, .ply, .stl) +* ``temp/`` - Temporary files + +Project Info +^^^^^^^^^^^^ +* ``README.md`` - Project overview +* ``CHANGELOG.md`` - Feature changelog +* ``LICENSE`` - License information diff --git a/docs/devguide/style.rst b/docs/devguide/style.rst new file mode 100644 index 0000000..dac982b --- /dev/null +++ b/docs/devguide/style.rst @@ -0,0 +1,144 @@ +******************************************************************************** +Style +******************************************************************************** + +Python Code +=========== + +Use the compas style guide from the official documentation `documentation `_. + + +C++ Code +======== + +* Functions: ``snake_case`` +* Variables (local and public): ``snake_case`` +* Private members: ``_snake_case`` (prefix with _) +* Static members: ``s_snake_case`` (prefix with s and _) +* Constants: ``SNAKE_UPPER_CASE`` +* Class/Struct names: ``UpperCamelCase`` + +Namespaces +---------- + +.. code-block:: cpp + + // Do not use "using namespace std", specify namespace explicitly + std::vector points; + + // Do not use "using namespace Eigen", specify namespace explicitly + Eigen::MatrixXd vertices; + +Functions +--------- + +.. code-block:: cpp + + // Next line bracket style + void compute_geodesic_distance() + { + /* content */ + } + + // Always include Windows-specific handling when needed + #ifdef _WIN32 + #define NOMINMAX // Prevent Windows max/min macro conflicts + #endif + +Structures +---------- + +.. code-block:: cpp + + // Structure name uses UpperCamelCase + struct MeshData + { + // Structure attribute uses snake_case + const char* file_name; + Eigen::MatrixXd vertices; + Eigen::MatrixXi faces; + }; + + +Classes +------- + +.. code-block:: cpp + + // Class name uses UpperCamelCase + class GeodesicSolver + { + public: + GeodesicSolver(const double& tolerance); + virtual ~GeodesicSolver(); + + // Member functions use snake_case + void compute_distance() + { + // Local variable uses snake_case + double tolerance = 0.001; + } + + // Field indicator to separate functions and attributes + public: + int result_count; // Public member uses snake_case + + private: + void validate_mesh(); // Private function uses snake_case + double _tolerance; // Private member uses _snake_case + static int s_meshes; // Static member uses s_snake_case + const int MAX_COUNT = 100; // Constant uses SNAKE_UPPER_CASE + }; + +Docstrings +========== + +Use Doxygen-style comments with the following format: + +Functions and Methods +--------------------- + +.. code-block:: cpp + + /** + * @brief Short description of function + * @param[in] vertices Input mesh vertices (n x 3) + * @param[in] faces Input mesh faces (m x 3) + * @param[in] source_vertex Index of source vertex + * @return Tuple containing distance field and geodesic path + * @throws std::runtime_error if mesh is not manifold + */ + std::tuple> + compute_geodesic(const Eigen::MatrixXd& vertices, + const Eigen::MatrixXi& faces, + int source_vertex); + +Classes +------- + +.. code-block:: cpp + + /** + * @brief Solver for geodesic distance computation + * @details Implements both exact and heat method approaches + */ + class GeodesicSolver { + public: + /** + * @brief Constructor for geodesic solver + * @param method Method to use ("exact" or "heat") + * @param tolerance Computation tolerance + */ + GeodesicSolver(const std::string& method, double tolerance); + }; + +Member Variables +---------------- + +.. code-block:: cpp + + class GeodesicSolver { + private: + double _tolerance; //!< Tolerance for geodesic computation + std::string _method; //!< Method used ("exact" or "heat") + }; diff --git a/docs/devguide/types.rst b/docs/devguide/types.rst new file mode 100644 index 0000000..5542855 --- /dev/null +++ b/docs/devguide/types.rst @@ -0,0 +1,154 @@ +******************************************************************************** +Types +******************************************************************************** + +Type Conversion +=============== + +Matching C++/Python types often takes the most of the time and requires careful attention. When implementing C++/Python bindings, follow these key patterns from the existing files or implement your own. If there are specific types you want to implement, review the `nanobind tests `_ . Ask questions in discussion section for nanobind typing or follow previous issues. Current implementation provides examples for the following types: + + +* C++: + * Use ``Eigen::Ref`` for matrix parameters, e.g. to transfer mesh vertex coordinates. + * Return complex data as ``std::tuple`` types. + * Use ``std::vector`` for list copies otherwise use ``const std::vector &``. + * Use Eigen Matrix types in vectors ``const std::vector> &`` instead of reference type ``const std::vector> &``. + * On Windows, ensure ``NOMINMAX`` is defined before including any Windows headers to prevent max/min macro conflicts. + +* Python: + * Use ``float64`` for vertices and ``int32`` for faces in numpy arrays + * Enforce row-major (C-contiguous) order for matrices + * Use libigl's matrix types (e.g., ``Eigen::MatrixXd``, ``Eigen::MatrixXi``) + + +Type Conversion Patterns +======================== + +When implementing C++/Python bindings, follow these established patterns: + +Matrix Operations +----------------- + +Use ``Eigen::Ref`` for efficient matrix passing: + +.. code-block:: cpp + + void my_function(const Eigen::Ref& vertices, + const Eigen::Ref& faces); + +Return complex mesh data as tuples: + +.. code-block:: cpp + + return std::tuple my_function(); + +Enforce proper numpy array types using float64 and int32 in C-contiguous order: + +.. code-block:: python + + import numpy as np + from compas_libigl._nanobind import my_submodule + + # Convert mesh vertices and faces to proper numpy arrays + vertices = np.asarray(mesh.vertices, dtype=np.float64) + faces = np.asarray(mesh.faces, dtype=np.int32) + + # Pass to C++ function + V, F = my_submodule.my_function(vertices, faces) + + +Vector Types +------------ + +For list data, choose between ``std::vector`` for value copies, ``const std::vector&`` for references, and ``std::vector>`` for matrix vectors. + +Bind vector types explicitly: + +.. code-block:: cpp + + // In module initialization + nb::bind_vector>(m, "VectorDouble"); + +Access in Python: + +.. code-block:: python + + # Get vector result + vector_result = my_function() + # Access elements by index + x, y, z = vector_result[0], vector_result[1], vector_result[2] + +Follow existing patterns in: ``boundaries.cpp``, ``curvature.cpp``, ``geodistance.cpp``, ``intersections.cpp``, ``isolines.cpp``, etc. + +Type Conversion Best Practices +============================== + +When implementing new functionality: + +* Matrix Operations: + + .. code-block:: cpp + + // GOOD: Use Eigen::Ref for matrix parameters + void my_function(Eigen::Ref vertices); + + // BAD: Don't use raw matrices + void my_function(Eigen::MatrixXd vertices); + +* Return Types: + + .. code-block:: cpp + + // GOOD: Return complex data as tuples + std::tuple my_mesh_operation(); + + // BAD: Don't use output parameters + void my_mesh_operation(Eigen::MatrixXd& out_vertices); + +* Vector Handling: + + .. code-block:: cpp + + // GOOD: Use const references for input vectors + void my_function(const std::vector& input); + + // GOOD: Return vectors by value + std::vector MyOperation(); + + // BAD: Don't use non-const references + void my_function(std::vector& input); + +* Matrix Vectors: + + .. code-block:: cpp + + // GOOD: Use Matrix types in vectors + std::vector> points; + + // BAD: Don't use Ref types in vectors + std::vector> points; + +* Python Integration: + + .. code-block:: python + + # GOOD: Enforce proper types + vertices = np.array(points, dtype=np.float64) + faces = np.array(indices, dtype=np.int32) + + # BAD: Don't rely on automatic conversion + vertices = points # type not enforced + faces = indices # type not enforced + +* Windows-Specific: + + .. code-block:: cpp + + // GOOD: Define NOMINMAX before Windows headers + #ifdef _WIN32 + #define NOMINMAX + #endif + #include + + // BAD: Don't use Windows headers without NOMINMAX + #include // May cause conflicts with std::min/max diff --git a/docs/examples/curvature.py b/docs/examples/curvature.py deleted file mode 100644 index 3e98b98..0000000 --- a/docs/examples/curvature.py +++ /dev/null @@ -1,50 +0,0 @@ -import compas -import compas_libigl as igl -from compas.colors import Color -from compas.datastructures import Mesh -from compas.geometry import Line -from compas.geometry import Point -from compas.geometry import Vector -from compas_viewer import Viewer - -# ============================================================================== -# Input geometry -# ============================================================================== - -mesh = Mesh.from_obj(compas.get("tubemesh.obj")) - -trimesh = mesh.copy() -trimesh.quads_to_triangles() - -# ============================================================================== -# curvature -# ============================================================================== - -curvature = igl.trimesh_gaussian_curvature(trimesh.to_vertices_and_faces()) - -# ============================================================================== -# Visualisation -# ============================================================================== - -viewer = Viewer(width=1600, height=900) -# viewer.view.camera.position = [1, -6, 2] -# viewer.view.camera.look_at([1, 1, 1]) - -viewer.scene.add(mesh, opacity=0.7, show_points=False) - -for vertex in mesh.vertices(): - if mesh.is_vertex_on_boundary(vertex): - continue - - point = Point(*mesh.vertex_coordinates(vertex)) - normal = Vector(*mesh.vertex_normal(vertex)) - c = curvature[vertex] - normal.scale(10 * c) - - viewer.scene.add( - Line(point, point + normal), - linecolor=(Color.red() if c > 0 else Color.blue()), - linewidth=2, - ) - -viewer.show() diff --git a/docs/examples/boundaries.py b/docs/examples/example_boundaries.py similarity index 94% rename from docs/examples/boundaries.py rename to docs/examples/example_boundaries.py index 8fe19e6..cc91cac 100644 --- a/docs/examples/boundaries.py +++ b/docs/examples/example_boundaries.py @@ -34,17 +34,21 @@ # viewer.view.camera.position = [8, -7, 1] # viewer.view.camera.look_at([1, 0, 0]) + +for vertices in boundaries: + vertices = list(vertices) + vertices.append(vertices[0]) + points = mesh.vertices_attributes("xyz", keys=vertices) + polyline = Polyline(points) + viewer.scene.add(polyline, linecolor=Color.red(), linewidth=3) + viewer.scene.add( mesh, facecolor=Color.green(), linecolor=Color.green().darkened(20), opacity=0.7, show_points=False, + show_lines=False, ) -for vertices in boundaries: - points = mesh.vertices_attributes("xyz", keys=vertices) - polyline = Polyline(points) - viewer.scene.add(polyline, linecolor=Color.red(), linewidth=3) - viewer.show() diff --git a/docs/examples/boundaries.rst b/docs/examples/example_boundaries.rst similarity index 74% rename from docs/examples/boundaries.rst rename to docs/examples/example_boundaries.rst index 64506ef..7347ded 100644 --- a/docs/examples/boundaries.rst +++ b/docs/examples/example_boundaries.rst @@ -2,9 +2,9 @@ Boundary loops ******************************************************************************** -.. figure:: /_images/boundaries.png +.. figure:: /_images/example_boundaries.png :figclass: figure :class: figure-img img-fluid -.. literalinclude:: boundaries.py +.. literalinclude:: example_boundaries.py :language: python diff --git a/docs/examples/example_curvature.rst b/docs/examples/example_curvature.rst new file mode 100644 index 0000000..76ce590 --- /dev/null +++ b/docs/examples/example_curvature.rst @@ -0,0 +1,20 @@ +******************************************************************************** +Curvature +******************************************************************************** + + +.. figure:: /_images/example_curvature_gaussian.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_curvature_gaussian.py + :language: python + + +.. figure:: /_images/example_curvature_principal.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_curvature_principal.py + :language: python + diff --git a/docs/examples/example_curvature_gaussian.py b/docs/examples/example_curvature_gaussian.py new file mode 100644 index 0000000..f4a57c0 --- /dev/null +++ b/docs/examples/example_curvature_gaussian.py @@ -0,0 +1,70 @@ +import compas +import compas_libigl as igl +from compas.colors import Color +from compas.colors.colormap import ColorMap +from compas.datastructures import Mesh +from compas.geometry import Point, Line +from compas_viewer import Viewer + +# ============================================================================== +# Input geometry +# ============================================================================== + +mesh = Mesh.from_obj(compas.get("tubemesh.obj")) +trimesh = mesh.copy() +trimesh.quads_to_triangles() + +# ============================================================================== +# Gaussian Curvature +# ============================================================================== + +vertices, faces = trimesh.to_vertices_and_faces() +gaussian_curvature = igl.trimesh_gaussian_curvature((vertices, faces)) + +# Get non-boundary vertex indices +non_boundary_vertices = [i for i in range(len(vertices)) if not trimesh.is_vertex_on_boundary(i)] + +# Prepare vertex colors based on Gaussian curvature (excluding boundary vertices) +min_gaussian = min(gaussian_curvature[i] for i in non_boundary_vertices) +max_gaussian = max(gaussian_curvature[i] for i in non_boundary_vertices) + + +# Create two-color maps for negative and positive curvature +cmap_negative = ColorMap.from_two_colors(Color.blue(), Color.yellow()) +cmap_positive = ColorMap.from_two_colors(Color.yellow(), Color.magenta()) + +# Create vertex colors dictionary +vertex_colors = {} +for i, k in enumerate(gaussian_curvature): + if trimesh.is_vertex_on_boundary(i): + # Set boundary vertices to black + vertex_colors[i] = Color.black() + else: + if k < 0: + vertex_colors[i] = cmap_negative(k, minval=min_gaussian, maxval=0) + else: + vertex_colors[i] = cmap_positive(k, minval=0, maxval=max_gaussian) + +# ============================================================================== +# Visualization +# ============================================================================== + +viewer = Viewer() + +# Add the colored mesh +viewer.scene.add(trimesh, use_vertexcolors=True, vertexcolor=vertex_colors) + +# Add normal vectors scaled by Gaussian curvature +normal_scale = -10 +for vertex in trimesh.vertices(): + if not trimesh.is_vertex_on_boundary(vertex): + point = Point(*trimesh.vertex_coordinates(vertex)) + normal = trimesh.vertex_normal(vertex) + k = gaussian_curvature[vertex] + + scaled_normal = [n * normal_scale * k for n in normal] + end_point = Point(*(p + n for p, n in zip(point, scaled_normal))) + + viewer.scene.add(Line(point, end_point), linecolor=Color.black(), linewidth=2) + +viewer.show() diff --git a/docs/examples/example_curvature_principal.py b/docs/examples/example_curvature_principal.py new file mode 100644 index 0000000..b521d6e --- /dev/null +++ b/docs/examples/example_curvature_principal.py @@ -0,0 +1,53 @@ +import compas +import numpy as np +import compas_libigl as igl +from compas.colors import Color +from compas.colors.colormap import ColorMap +from compas.datastructures import Mesh +from compas.geometry import Line, Point, Vector +from compas_viewer import Viewer +from compas_viewer.scene import BufferGeometry + +# ============================================================================== +# Input geometry +# ============================================================================== + +mesh = Mesh.from_obj(compas.get("tubemesh.obj")) +trimesh = mesh.copy() +trimesh.quads_to_triangles() + +# ============================================================================== +# Principal Curvature +# ============================================================================== + +vertices, faces = trimesh.to_vertices_and_faces() +PD1, PD2, PV1, PV2 = igl.trimesh_principal_curvature((vertices, faces)) + +# ============================================================================== +# Visualization +# ============================================================================== + +viewer = Viewer() + +# Add the colored mesh +viewer.scene.add(mesh, show_lines=False, show_points=False) + +# Scale factor for principal direction lines +principal_scale = 0.3 + +# Add principal direction lines +for vertex_idx, point in enumerate(vertices): + if not trimesh.is_vertex_on_boundary(vertex_idx): + point = Point(*point) + + # Scale lines by curvature magnitude + pd1 = Vector(*PD1[vertex_idx]).scaled(principal_scale * abs(PV1[vertex_idx])) + pd2 = Vector(*PD2[vertex_idx]).scaled(principal_scale * abs(PV2[vertex_idx])) + + # Maximum principal direction (black) + viewer.scene.add(Line(point - pd1, point + pd1), linecolor=Color.black(), linewidth=20) + # Minimum principal direction (black) + viewer.scene.add(Line(point - pd2, point + pd2), linecolor=Color.black(), linewidth=20) + + +viewer.show() diff --git a/docs/examples/geodistance.py b/docs/examples/example_geodistance.py similarity index 86% rename from docs/examples/geodistance.py rename to docs/examples/example_geodistance.py index 9225948..8a24847 100644 --- a/docs/examples/geodistance.py +++ b/docs/examples/example_geodistance.py @@ -20,7 +20,7 @@ # ============================================================================== source = trimesh.vertex_sample(size=1)[0] -distance = igl.trimesh_geodistance(trimesh.to_vertices_and_faces(), source, method="heat") +distance = igl.trimesh_geodistance(trimesh.to_vertices_and_faces(), source, method="exact") # ============================================================================== # Visualize @@ -36,6 +36,8 @@ for d, vertex in zip(distance, mesh.vertices()): point = Point(*mesh.vertex_attributes(vertex, "xyz")) - viewer.scene.add(point, pointsize=30, pointcolor=cmap(d, min(distance), max(distance))) + viewer.scene.add(point, pointsize=10, pointcolor=cmap(d, min(distance), max(distance))) + +viewer.scene.add(Point(*mesh.vertex_attributes(source, "xyz")), pointsize=30, pointcolor=Color.black()) viewer.show() diff --git a/docs/examples/example_geodistance.rst b/docs/examples/example_geodistance.rst new file mode 100644 index 0000000..3b3d1f5 --- /dev/null +++ b/docs/examples/example_geodistance.rst @@ -0,0 +1,17 @@ +******************************************************************************** +Geodesic Distance +******************************************************************************** + +.. figure:: /_images/example_geodistance.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_geodistance.py + :language: python + +.. figure:: /_images/example_geodistance_multiple.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_geodistance_multiple.py + :language: python diff --git a/docs/examples/example_geodistance_multiple.py b/docs/examples/example_geodistance_multiple.py new file mode 100644 index 0000000..0228958 --- /dev/null +++ b/docs/examples/example_geodistance_multiple.py @@ -0,0 +1,55 @@ +import compas +import compas_libigl as igl +from compas.colors import Color, ColorMap +from compas.datastructures import Mesh +from compas.geometry import Point, Scale, Rotation +from compas_viewer import Viewer +import math +import numpy as np + +# ============================================================================== +# Input +# ============================================================================== + +mesh = Mesh.from_obj(compas.get("tubemesh.obj")) + +trimesh = mesh.copy() +trimesh.quads_to_triangles() + + +# ============================================================================== +# Get boundary vertices as source points +# ============================================================================== + +boundary_vertices = list(trimesh.vertices_on_boundary()) + +# ============================================================================== +# Compute geodesic distances from all boundary vertices at once +# ============================================================================== + +# Calculate geodesic distances using the new multiple source points function +distances = igl.trimesh_geodistance_multiple(trimesh.to_vertices_and_faces(), boundary_vertices, method="exact") + +# ============================================================================== +# Visualization +# ============================================================================== + +viewer = Viewer(width=1600, height=900) + +# Add base mesh +viewer.scene.add(mesh, show_points=False, show_lines=True, linewidth=1) + +# Create color gradient +cmap = ColorMap.from_mpl("viridis") + +# Visualize distances as colored points +for vertex, dist in zip(mesh.vertices(), distances): + point = Point(*mesh.vertex_attributes(vertex, "xyz")) + viewer.scene.add(point, pointsize=10, pointcolor=cmap(dist, min(distances), max(distances))) + +# Highlight boundary vertices +for vertex in boundary_vertices: + point = Point(*mesh.vertex_attributes(vertex, "xyz")) + viewer.scene.add(point, pointsize=20, pointcolor=Color.black()) + +viewer.show() diff --git a/docs/examples/intersections.py b/docs/examples/example_intersections.py similarity index 100% rename from docs/examples/intersections.py rename to docs/examples/example_intersections.py diff --git a/docs/examples/intersections.rst b/docs/examples/example_intersections.rst similarity index 67% rename from docs/examples/intersections.rst rename to docs/examples/example_intersections.rst index fdd980a..e92a7aa 100644 --- a/docs/examples/intersections.rst +++ b/docs/examples/example_intersections.rst @@ -1,10 +1,10 @@ ******************************************************************************** -Ray/Mesh Intersections +Mesh-ray intersections ******************************************************************************** -.. figure:: /_images/intersections.png +.. figure:: /_images/example_intersections.png :figclass: figure :class: figure-img img-fluid -.. literalinclude:: intersections.py +.. literalinclude:: example_intersections.py :language: python diff --git a/docs/examples/isolines.py b/docs/examples/example_isolines.py similarity index 52% rename from docs/examples/isolines.py rename to docs/examples/example_isolines.py index 207d84c..604b8b6 100644 --- a/docs/examples/isolines.py +++ b/docs/examples/example_isolines.py @@ -1,13 +1,11 @@ import math import compas_libigl as igl -from compas.colors import ColorMap +from compas.colors import ColorMap, Color from compas.datastructures import Mesh -from compas.geometry import Polyline from compas.geometry import Rotation from compas.geometry import Scale -# from compas_view2.objects import Collection from compas_viewer import Viewer # ============================================================================== @@ -27,36 +25,35 @@ # ============================================================================== scalars = mesh.vertices_attribute("z") -vertices, edges = igl.trimesh_isolines(mesh.to_vertices_and_faces(), scalars, 100) -isolines = igl.groupsort_isolines(vertices, edges) +minval = min(scalars) +maxval = max(scalars) + +# Create evenly spaced values +num_isolines = 100 +isovalues = [minval + i * (maxval - minval) / (num_isolines - 1) for i in range(num_isolines)] + +vertices, edges, indices = igl.trimesh_isolines(mesh.to_vertices_and_faces(), scalars, isovalues) + +isolines = igl.groupsort_isolines(vertices, edges, indices) + # ============================================================================== # Visualisation # ============================================================================== -viewer = Viewer(width=1600, height=900) -# viewer.view.camera.position = [8, -7, 1] -# viewer.view.camera.look_at([1, 0, 0]) -viewer.scene.add(mesh, opacity=0.7, show_lines=False, show_points=False) +viewer = Viewer() + -minval = min(scalars) - 0.01 -maxval = max(scalars) + 0.01 +minval = min(scalars) + 0.01 +maxval = max(scalars) - 0.01 -cmap = ColorMap.from_rgb() +values = [(v - minval) / (maxval - minval) for v in scalars] -for value, paths in isolines: - polylines = [] - for path in paths: - points = [vertices[path[0][0]]] - for i, j in path: - points.append(vertices[j]) - polylines.append(Polyline(points)) +cmap = ColorMap.from_mpl("viridis") - # viewer.scene.add( - # Collection(polylines), - # linecolor=cmap(value, minval=minval, maxval=maxval), - # linewidth=3, - # ) +for i, isolines in enumerate(isolines): + color = cmap(values[i], minval, maxval) + viewer.scene.add(isolines, linecolor=color, linewidth=3) viewer.show() diff --git a/docs/examples/isolines.rst b/docs/examples/example_isolines.rst similarity index 75% rename from docs/examples/isolines.rst rename to docs/examples/example_isolines.rst index f5bccda..13d99a9 100644 --- a/docs/examples/isolines.rst +++ b/docs/examples/example_isolines.rst @@ -2,9 +2,9 @@ Isolines ******************************************************************************** -.. figure:: /_images/isolines.png +.. figure:: /_images/example_isolines.png :figclass: figure :class: figure-img img-fluid -.. literalinclude:: isolines.py +.. literalinclude:: example_isolines.py :language: python diff --git a/docs/examples/massmatrix.py b/docs/examples/example_massmatrix.py similarity index 85% rename from docs/examples/massmatrix.py rename to docs/examples/example_massmatrix.py index 028c51a..1b654a0 100644 --- a/docs/examples/massmatrix.py +++ b/docs/examples/example_massmatrix.py @@ -1,5 +1,6 @@ import compas import compas_libigl as igl +import numpy as np from compas.colors import ColorMap from compas.datastructures import Mesh from compas_viewer import Viewer @@ -18,6 +19,8 @@ # ============================================================================== mass = igl.trimesh_massmatrix(trimesh.to_vertices_and_faces()) +# Convert sparse diagonal to dense array +mass_diag = np.array(mass.diagonal()) # ============================================================================== # Visualisation @@ -25,8 +28,8 @@ cmap = ColorMap.from_rgb() -minval = min(mass) -maxval = max(mass) +minval = mass_diag.min() +maxval = mass_diag.max() viewer = Viewer(width=1600, height=900) # viewer.view.camera.position = [1, -6, 2] @@ -34,7 +37,7 @@ viewer.scene.add(mesh, show_points=False) -for m, vertex in zip(mass, mesh.vertices()): +for m, vertex in zip(mass_diag, mesh.vertices()): point = mesh.vertex_point(vertex) viewer.scene.add(point, pointsize=30, pointcolor=cmap(m, minval, maxval)) diff --git a/docs/examples/curvature.rst b/docs/examples/example_massmatrix.rst similarity index 71% rename from docs/examples/curvature.rst rename to docs/examples/example_massmatrix.rst index 41f7adc..b20d1fb 100644 --- a/docs/examples/curvature.rst +++ b/docs/examples/example_massmatrix.rst @@ -1,10 +1,10 @@ ******************************************************************************** -Curvature +Mass matrix ******************************************************************************** -.. figure:: /_images/curvature.png +.. figure:: /_images/example_massmatrix.png :figclass: figure :class: figure-img img-fluid -.. literalinclude:: curvature.py +.. literalinclude:: example_massmatrix.py :language: python diff --git a/docs/examples/example_meshing.rst b/docs/examples/example_meshing.rst new file mode 100644 index 0000000..5229c1a --- /dev/null +++ b/docs/examples/example_meshing.rst @@ -0,0 +1,33 @@ +******************************************************************************** +Meshing +******************************************************************************** + +.. figure:: /_images/example_meshing_plane.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_meshing_plane.py + :language: python + +.. figure:: /_images/example_meshing_planes.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_meshing_planes.py + :language: python + +.. figure:: /_images/example_meshing_waves.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_meshing_waves.py + :language: python + +.. figure:: /_images/example_meshing_geodesic.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_meshing_geodesic.py + :language: python + + diff --git a/docs/examples/example_meshing_geodesic.py b/docs/examples/example_meshing_geodesic.py new file mode 100644 index 0000000..4d7afaa --- /dev/null +++ b/docs/examples/example_meshing_geodesic.py @@ -0,0 +1,64 @@ +import compas +import compas_libigl as igl +from compas.colors import ColorMap +from compas.geometry import Point +from compas.datastructures import Mesh +from compas.geometry import Rotation, Scale +import math +from compas_viewer import Viewer + +# ============================================================================== +# Input geometry +# ============================================================================== + +mesh = Mesh.from_off(igl.get("camelhead.off")) +R = Rotation.from_axis_and_angle([1, 0, 0], math.radians(90)) +S = Scale.from_factors([10, 10, 10]) +mesh.transform(S * R) + +# Convert to triangle mesh +trimesh = mesh.copy() +trimesh.quads_to_triangles() + +# ============================================================================== +# Compute geodesic distances from boundary +# ============================================================================== + +# Get boundary vertices +boundary_vertices = list(trimesh.vertices_on_boundary()) + +# Calculate geodesic distances using multiple source points +distances = igl.trimesh_geodistance_multiple(trimesh.to_vertices_and_faces(), boundary_vertices, method="exact") + +# ============================================================================== +# Create isolines and remesh +# ============================================================================== + +# Get range and create isolines +min_dist, max_dist = min(distances), max(distances) +num_isolines = 5 +isovalues = [min_dist + i * (max_dist - min_dist) / num_isolines for i in range(1, num_isolines + 1)] + +# Split mesh along isolines of geodesic distance +V, F, S, G = igl.trimesh_remesh_along_isolines(trimesh.to_vertices_and_faces(), distances, isovalues) + +# ============================================================================== +# Visualization +# ============================================================================== + +viewer = Viewer(width=1600, height=900) + +# Create separate mesh for each geodesic distance group +color_map = ColorMap.from_mpl("viridis") +for i, group_id in enumerate(sorted(set(G))): + faces = [F[j] for j in range(len(F)) if G[j] == group_id] + if faces: + piece = Mesh.from_vertices_and_faces(V, faces) + viewer.scene.add(piece, facecolor=color_map(i / (num_isolines + 1)), show_lines=False, linewidth=1, linecolor=(0.2, 0.2, 0.2)) + +# Highlight boundary vertices +for vertex in boundary_vertices: + point = Point(*trimesh.vertex_attributes(vertex, "xyz")) + viewer.scene.add(point, pointsize=20, pointcolor=(1, 0, 0)) + +viewer.show() diff --git a/docs/examples/example_meshing_plane.py b/docs/examples/example_meshing_plane.py new file mode 100644 index 0000000..e718155 --- /dev/null +++ b/docs/examples/example_meshing_plane.py @@ -0,0 +1,29 @@ +import math +import compas_libigl as igl +from compas.colors import Color +from compas.geometry import Plane, Rotation, Scale +from compas.datastructures import Mesh +from compas_viewer import Viewer + +# Load and transform mesh +mesh = Mesh.from_off(igl.get_beetle()) +R = Rotation.from_axis_and_angle([1, 0, 0], math.radians(90)) +S = Scale.from_factors([10, 10, 10]) +mesh.transform(S * R) + +# Calculate signed distances to plane +plane = Plane([0, 0, 0], [0, 1, 1]) +distances = [plane.normal.dot(plane.point - mesh.vertex_coordinates(v)) for v in mesh.vertices()] + +# Split mesh along plane +V, F, L = igl.trimesh_remesh_along_isoline(mesh.to_vertices_and_faces(), distances, 0) + +# Create meshes for parts below and above plane +below = Mesh.from_vertices_and_faces(V, [F[i] for i, l in enumerate(L) if l == 0]) +above = Mesh.from_vertices_and_faces(V, [F[i] for i, l in enumerate(L) if l == 1]) + +# Visualize +viewer = Viewer() +viewer.scene.add(below, facecolor=Color.red(), show_lines=False) +viewer.scene.add(above, facecolor=Color.blue(), show_lines=False) +viewer.show() diff --git a/docs/examples/example_meshing_planes.py b/docs/examples/example_meshing_planes.py new file mode 100644 index 0000000..28987d6 --- /dev/null +++ b/docs/examples/example_meshing_planes.py @@ -0,0 +1,37 @@ +import compas_libigl as igl +from compas.colors import ColorMap +from compas.geometry import Rotation +from compas.geometry import Scale +import math +from compas.datastructures import Mesh +from compas_viewer import Viewer + +# Load mesh +mesh = Mesh.from_off(igl.get_beetle()) +R = Rotation.from_axis_and_angle([1, 0, 0], math.radians(90)) +S = Scale.from_factors([10, 10, 10]) +mesh.transform(S * R) + +# Get z-coordinates as scalar field +scalar_values = mesh.vertices_attribute("z") +min_val, max_val = min(scalar_values), max(scalar_values) + +# Create 4 isolines +num_isolines = 4 +isovalues = [min_val + i * (max_val - min_val) / num_isolines for i in range(1, num_isolines + 1)] + +# Split mesh along isolines +V, F, S, G = igl.trimesh_remesh_along_isolines(mesh.to_vertices_and_faces(), scalar_values, isovalues) + +# Visualize each piece in a different color +color_map = ColorMap.from_mpl("viridis") +viewer = Viewer() + +# Create separate mesh for each group +for i, group_id in enumerate(set(G)): + faces = [F[j] for j in range(len(F)) if G[j] == group_id] + if faces: + piece = Mesh.from_vertices_and_faces(V, faces) + viewer.scene.add(piece, facecolor=color_map(i / (num_isolines + 1)), show_lines=False) + +viewer.show() diff --git a/docs/examples/example_meshing_waves.py b/docs/examples/example_meshing_waves.py new file mode 100644 index 0000000..21a24b8 --- /dev/null +++ b/docs/examples/example_meshing_waves.py @@ -0,0 +1,42 @@ +import compas_libigl as igl +from compas.colors import ColorMap +from compas.geometry import Rotation, Scale, Point, distance_point_point +import math +import numpy as np +from compas.datastructures import Mesh +from compas_viewer import Viewer + +# Load mesh +mesh = Mesh.from_off(igl.get_beetle()) +R = Rotation.from_axis_and_angle([1, 0, 0], math.radians(90)) +S = Scale.from_factors([10, 10, 10]) +mesh.transform(S * R) + +scalar_values = [] +frequency = 2 +for v in mesh.vertices(): + x, y, z = mesh.vertex_coordinates(v) + val = math.sin(frequency * x) * math.cos(frequency * y) + math.sin(frequency * z) + scalar_values.append(val) + + +# Get range and create isolines +min_val, max_val = min(scalar_values), max(scalar_values) +num_isolines = 7 +isovalues = [min_val + i * (max_val - min_val) / num_isolines for i in range(1, num_isolines + 1)] + +# Split mesh along isolines +V, F, S, G = igl.trimesh_remesh_along_isolines(mesh.to_vertices_and_faces(), scalar_values, isovalues) + +# Visualize each piece in a different color +color_map = ColorMap.from_mpl("plasma") +viewer = Viewer() + +# Create separate mesh for each group +for i, group_id in enumerate(set(G)): + faces = [F[j] for j in range(len(F)) if G[j] == group_id] + if faces: + piece = Mesh.from_vertices_and_faces(V, faces) + viewer.scene.add(piece, facecolor=color_map(i / (num_isolines + 1)), show_lines=False) + +viewer.show() diff --git a/docs/examples/parametrisation.py b/docs/examples/example_parametrisation.py similarity index 80% rename from docs/examples/parametrisation.py rename to docs/examples/example_parametrisation.py index 5b709b7..cb8306c 100644 --- a/docs/examples/parametrisation.py +++ b/docs/examples/example_parametrisation.py @@ -1,15 +1,20 @@ import compas_libigl as igl from compas.datastructures import Mesh -from compas.geometry import Scale +from compas.geometry import Rotation, Scale from compas.geometry import Translation from compas_viewer import Viewer +import math # ============================================================================== # Input geometry # ============================================================================== mesh = Mesh.from_off(igl.get("camelhead.off")) +R0 = Rotation.from_axis_and_angle([1, 0, 0], math.radians(90)) +R1 = Rotation.from_axis_and_angle([0, 1, 0], math.radians(90)) +mesh.transform(R0 * R1) +mesh.translate([0, 0.5, 0.5]) mesh_lscm = mesh.copy() mesh_lscm.vertices_attribute("z", 0) @@ -17,6 +22,7 @@ # Least-squares conformal map # ============================================================================== +# lscm_uv = igl.trimesh_harmonic(mesh.to_vertices_and_faces()) lscm_uv = igl.trimesh_lscm(mesh.to_vertices_and_faces()) for index, key in enumerate(mesh.vertices()): diff --git a/docs/examples/example_parametrisation.rst b/docs/examples/example_parametrisation.rst new file mode 100644 index 0000000..078ffb1 --- /dev/null +++ b/docs/examples/example_parametrisation.rst @@ -0,0 +1,10 @@ +******************************************************************************** +Mesh parameterization +******************************************************************************** + +.. figure:: /_images/example_parametrisation.png + :figclass: figure + :class: figure-img img-fluid + +.. literalinclude:: example_parametrisation.py + :language: python diff --git a/docs/examples/planarize.py b/docs/examples/example_planarize.py similarity index 66% rename from docs/examples/planarize.py rename to docs/examples/example_planarize.py index 6a910fe..a2a64b4 100644 --- a/docs/examples/planarize.py +++ b/docs/examples/example_planarize.py @@ -3,7 +3,6 @@ from compas.colors import Color from compas.colors import ColorMap from compas.datastructures import Mesh -from compas.datastructures import mesh_flatness from compas_viewer import Viewer # ============================================================================== @@ -14,31 +13,26 @@ MAXDEV = 0.005 KMAX = 500 -mesh = Mesh.from_obj(compas.get("tubemesh.obj")) +mesh_not_planarized = Mesh.from_obj(compas.get("tubemesh.obj")) +mesh_not_planarized.name = "Not Planarized" # ============================================================================== # Planarize # ============================================================================== -V, F = mesh.to_vertices_and_faces() +V, F = mesh_not_planarized.to_vertices_and_faces() V2 = igl.quadmesh_planarize((V, F), KMAX, MAXDEV) # ============================================================================== # Visualize # ============================================================================== -mesh = Mesh.from_vertices_and_faces(V2, F) -dev = mesh_flatness(mesh, maxdev=TOL) +mesh_planarized = Mesh.from_vertices_and_faces(V2, F) +mesh_planarized.name = "Planarized" cmap = ColorMap.from_two_colors(Color.white(), Color.blue()) viewer = Viewer(width=1600, height=900) -# viewer.view.camera.position = [1, -6, 2] -# viewer.view.camera.look_at([1, 1, 1]) - -viewer.scene.add( - mesh, - facecolor={face: (cmap(dev[face]) if dev[face] <= 1.0 else Color.red()) for face in mesh.faces()}, - show_points=False, -) +viewer.scene.add(mesh_not_planarized, show_faces=False) +viewer.scene.add(mesh_planarized) viewer.show() diff --git a/docs/examples/geodistance.rst b/docs/examples/example_planarize.rst similarity index 71% rename from docs/examples/geodistance.rst rename to docs/examples/example_planarize.rst index d52eac2..5cdaa15 100644 --- a/docs/examples/geodistance.rst +++ b/docs/examples/example_planarize.rst @@ -1,10 +1,10 @@ ******************************************************************************** -Geodesic Distance +Planarization ******************************************************************************** -.. figure:: /_images/geodistance.png +.. figure:: /_images/example_planarize.png :figclass: figure :class: figure-img img-fluid -.. literalinclude:: geodistance.py +.. literalinclude:: example_planarize.py :language: python diff --git a/docs/examples/massmatrix.rst b/docs/examples/massmatrix.rst deleted file mode 100644 index c4c61d7..0000000 --- a/docs/examples/massmatrix.rst +++ /dev/null @@ -1,10 +0,0 @@ -******************************************************************************** -Mesh Mass Matrix -******************************************************************************** - -.. figure:: /_images/massmatrix.png - :figclass: figure - :class: figure-img img-fluid - -.. literalinclude:: massmatrix.py - :language: python diff --git a/docs/examples/parametrisation.rst b/docs/examples/parametrisation.rst deleted file mode 100644 index 9f17b87..0000000 --- a/docs/examples/parametrisation.rst +++ /dev/null @@ -1,10 +0,0 @@ -******************************************************************************** -Mesh Parametrisations -******************************************************************************** - -.. figure:: /_images/parametrisation.png - :figclass: figure - :class: figure-img img-fluid - -.. literalinclude:: parametrisation.py - :language: python diff --git a/docs/examples/planarize.rst b/docs/examples/planarize.rst deleted file mode 100644 index 129fd73..0000000 --- a/docs/examples/planarize.rst +++ /dev/null @@ -1,10 +0,0 @@ -******************************************************************************** -Quad Mesh Planarisation -******************************************************************************** - -.. figure:: /_images/planarize.png - :figclass: figure - :class: figure-img img-fluid - -.. literalinclude:: planarize.py - :language: python diff --git a/docs/index.rst b/docs/index.rst index 930bd8b..aee7e6b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ compas_libigl COMPAS Libigl provides a Python binding for `Libigl `_, a state-of-the-art C++ geometry processing library. -The binding is generated with `Pybind11 `_ and focusses on specific functionality: +The binding is generated with `Nanobind `_ and focusses on specific functionality: * Geodesic distance calculation * Scalarfield isolines @@ -17,6 +17,7 @@ The binding is generated with `Pybind11 installation + devguide tutorial examples api diff --git a/docs/installation.rst b/docs/installation.rst index 38dc710..77fddf8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -5,36 +5,29 @@ Installation Stable ====== -On ``conda-forge``, ``compas_libigl`` is available for Python 3.8, 3.9, and 3.10 on Windows, macOS, and Linux. +Stable releases of :mod:`compas_libigl` can be installed via ``conda-forge``. .. code-block:: bash - conda install -c conda-forge compas_libigl + conda create -n libigl -c conda-forge compas_libigl - -Dev Install -=========== - -Adevelopment version of :mod:`compas_libigl` can be installed "from source" using a combination of ``conda`` and ``pip``. - -Create a ``conda`` environment with the required dependencies and activate it. -Note that you can choose a different name for the environment than ``igl-dev``. +or install in your python environment via pip: .. code-block:: bash + + pip install compas_libigl - conda create -n igl-dev python=3.9 git cmake">=3.14" boost eigen=3.3 pybind11 - conda activate igl -Get a local copy of the source code of :mod:`compas_libigl` with all submodules. +Several examples use the COMPAS Viewer for visualisation. +To install :mod:`compas_viewer` in the same environment .. code-block:: bash - git clone --recursive https://github.com/compas-dev/compas_libigl.git + conda activate libigl + pip install compas_viewer -Install the package in "editable" mode using ``pip``. -Note that this will automatically build the C++ extension. -.. code-block:: bash +Dev Install +=========== - cd compas_libigl - pip install -e . +See :doc:`devguide`. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 221d8c3..2ce95f1 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -5,112 +5,90 @@ Tutorial .. rst-class:: lead :mod:`compas_libigl` provides bindings for the libigl library. -It doesn't cover the entire library, but only for specific functions. -Currently, the following functions are supported: - -* :func:`compas_libigl.intersection_ray_mesh` -* :func:`compas_libigl.intersection_rays_mesh` -* :func:`compas_libigl.trimesh_boundaries` -* :func:`compas_libigl.trimesh_gaussian_curvature` -* :func:`compas_libigl.trimesh_principal_curvature` -* :func:`compas_libigl.trimesh_geodistance` -* :func:`compas_libigl.trimesh_isolines` -* :func:`compas_libigl.trimesh_massmatrix` -* :func:`compas_libigl.trimesh_harmonic` -* :func:`compas_libigl.trimesh_lscm` -* :func:`compas_libigl.trimesh_remesh_along_isoline` -* :func:`compas_libigl.quadmesh_planarize` +It doesn't cover the entire library, but provides bindings for specific geometry processing functions. +The functions are organized into the following categories: +Mesh Analysis +============= -Input/Output -============ - -The function signatures of the bindings are similar to the original libigl functions. -Meshes are represented by a tuple containing a list/array of vertices and a list/array of faces. -Most functions require the input mesh to be a triangle mesh. - -.. code-block:: python +Boundaries and Intersections +---------------------------- - import compas - import compas_libigl - from compas.datastructures import Mesh +* :func:`compas_libigl.trimesh_boundaries` - Compute boundary loops of a mesh +* :func:`compas_libigl.intersection_ray_mesh` - Compute intersection of a ray with a mesh +* :func:`compas_libigl.intersection_rays_mesh` - Compute intersections of multiple rays with a mesh - mesh = Mesh.from_obj(compas.get("tubemesh.obj")) - mesh.quads_to_triangles() +Curvature Analysis +------------------ - V, F = mesh.to_vertices_and_faces() +* :func:`compas_libigl.trimesh_gaussian_curvature` - Compute Gaussian curvature at vertices +* :func:`compas_libigl.trimesh_principal_curvature` - Compute principal curvatures and directions - source = trimesh.vertex_sample(size=1)[0] - distance = compas_libigl.trimesh_geodistance( - (V, F), - source, - method="heat", - ) +Geodesic Distances +------------------ +* :func:`compas_libigl.trimesh_geodistance` - Compute geodesic distance from a source vertex +* :func:`compas_libigl.trimesh_geodistance_multiple` - Compute geodesic distances from multiple source vertices -Both Python lists and Numpy arrays are supported. +Mass Properties +--------------- -.. code-block:: python +* :func:`compas_libigl.trimesh_massmatrix` - Compute the mass matrix - import numpy - import compas - import compas_libigl - from compas.datastructures import Mesh +Mesh Processing +=============== - mesh = Mesh.from_obj(compas.get("tubemesh.obj")) - mesh.quads_to_triangles() +Remeshing and Isolines +---------------------- - vertices, faces = mesh.to_vertices_and_faces() - V = numpy.array(vertices, dtype=float) - F = numpy.array(faces, dtype=int) +* :func:`compas_libigl.trimesh_isolines` - Extract isolines from a scalar field +* :func:`compas_libigl.groupsort_isolines` - Sort and group isolines +* :func:`compas_libigl.trimesh_remesh_along_isoline` - Remesh along a single isoline +* :func:`compas_libigl.trimesh_remesh_along_isolines` - Remesh along multiple isolines - source = trimesh.vertex_sample(size=1)[0] - distance = compas_libigl.trimesh_geodistance( - (V, F), - source, - method="heat", - ) +Parameterization +---------------- +* :func:`compas_libigl.trimesh_harmonic` - Compute harmonic parameterization +* :func:`compas_libigl.trimesh_lscm` - Compute least squares conformal mapping -Pluggables -========== +Mesh Optimization +----------------- +* :func:`compas_libigl.quadmesh_planarize` - Planarize quad mesh faces -Visualisation -============= +Utilities +========= +* :func:`compas_libigl.get` - Get sample geometry files +* :func:`compas_libigl.get_beetle` - Get the beetle mesh +* :func:`compas_libigl.get_armadillo` - Get the armadillo mesh -Working in Rhino/Grasshopper -============================ +Input/Output +============ -The bindings are generated with PyBind11 and wrap the C++ code of libigl. -Therefore, the bindings are not compatible with IronPython and cannot be used in Rhino/Grasshopper directly. -However, they can be used in Rhino/Grasshopper through RPC. +The function signatures of the bindings are similar to the original libigl functions. +Meshes are represented by a tuple containing a list/array of vertices and a list/array of faces. +Most functions require the input mesh to be a triangle mesh. .. code-block:: python - import compas - from compas.rpc import Proxy + import compas_libigl as igl from compas.datastructures import Mesh - from compas.datastructures import mesh_flatness - from compas.colors import Color, ColorMap - from compas.artists import Artist - - compas_libigl = Proxy('compas_libigl') - mesh = Mesh.from_obj(compas.get("tubemesh.obj")) + # Load a mesh + mesh = Mesh.from_off(igl.get_beetle()) + # Convert to format expected by libigl functions V, F = mesh.to_vertices_and_faces() - V2 = compas_libigl.quadmesh_planarize((V, F), 100, 0.005) - - mesh = Mesh.from_vertices_and_faces(V2, F) - dev = mesh_flatness(mesh, maxdev=TOL) - cmap = ColorMap.from_two_colors(Color.white(), Color.blue()) + + # Call libigl function + result = igl.trimesh_gaussian_curvature(V, F) - facecolor={ - face: (cmap(dev[face]) if dev[face] <= 1.0 else Color.red()) - for face in mesh.faces() - } +Common Data Types +================= - artist = Artist(mesh, layer="libigl::quadmesh_planarize") - artist.draw(facecolor=facecolor, disjoint=True) +* Vertices (V): nx3 list/array of vertex coordinates +* Faces (F): mx3 list/array of vertex indices for triangle meshes +* Scalar fields: nx1 list/array of values per vertex +* Vector fields: nx3 list/array of vectors per vertex diff --git a/environment.yml b/environment.yml index 312220e..70ba752 100644 --- a/environment.yml +++ b/environment.yml @@ -1,13 +1,12 @@ +name: compas_libigl-dev channels: - conda-forge + - defaults dependencies: - - python>=3.9 - - pip>=19.0 - - git - - cmake>=3.14 - - boost - - eigen=3.3 - - pybind11 + - python <=3.12 + - pip + - cmake - compas - pip: - - -e .[dev] + - -r requirements-dev.txt + - . \ No newline at end of file diff --git a/ext/PLACEHOLDER b/ext/PLACEHOLDER deleted file mode 100644 index e69de29..0000000 diff --git a/ext/libigl b/ext/libigl deleted file mode 160000 index c645aac..0000000 --- a/ext/libigl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c645aac0c5852fad6fabe8c192fdc8675e607263 diff --git a/pyproject.toml b/pyproject.toml index 45debff..263bd66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,74 +1,92 @@ [build-system] -requires = ["setuptools>=66", "pybind11>=2.6.1"] -build-backend = "setuptools.build_meta" - -# ============================================================================ -# project info -# ============================================================================ +requires = ["scikit-build-core >=0.10", "nanobind >=1.3.2"] +build-backend = "scikit_build_core.build" [project] name = "compas_libigl" -dynamic = ["version", "dependencies", "optional-dependencies"] -description = "COMPAS friedly bindings for libigl." -keywords = [] -authors = [{ name = "tom van mele", email = "tom.v.mele@gmail.com" }] -license = { file = "LICENSE" } +description = "libigl wrapper for COMPAS." readme = "README.md" requires-python = ">=3.9" +authors = [ + { name = "tom van mele", email = "tom.v.mele@gmail.com" }, + { name = "Petras Vestartas", email = "petrasvestartas@gmail.com" } +] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Topic :: Scientific/Engineering", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", + "License :: OSI Approved :: BSD License" ] +dynamic = ["version"] [project.urls] -Homepage = "https://compas-dev.github.io/compas_libigl" -Repository = "https://github.com/compas-dev/compas_libigl" - -# ============================================================================ -# setuptools config -# ============================================================================ - -[tool.setuptools] -package-dir = { "" = "src" } -include-package-data = true -zip-safe = false - -[tool.setuptools.dynamic] -version = { attr = "compas_libigl.__version__" } -dependencies = { file = "requirements.txt" } -optional-dependencies = { dev = { file = "requirements-dev.txt" } } - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] +Homepage = "https://compas.dev/compas_libigl/latest/" # ============================================================================ -# replace pytest.ini +# pytest configuration # ============================================================================ [tool.pytest.ini_options] minversion = "6.0" -testpaths = ["tests", "src/compas_libigl"] +testpaths = ["tests"] python_files = ["test_*.py", "*_test.py", "test.py"] -addopts = ["-ra", "--strict-markers", "--doctest-glob=*.rst", "--tb=short"] +norecursedirs = [ + "external/*", + "build/*", + "dist/*", + "*.egg-info", + ".git", + ".tox", + ".env", + ".pytest_cache", + ".ruff_cache" +] +addopts = [ + "-ra", + "--strict-markers", + "--doctest-glob=*.rst", + "--tb=short", + "--import-mode=importlib" +] doctest_optionflags = [ "NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL", "ALLOW_UNICODE", "ALLOW_BYTES", - "NUMBER", + "NUMBER" ] # ============================================================================ -# replace bumpversion.cfg +# scikit-build configuration +# ============================================================================ + +[tool.scikit-build] +minimum-version = "build-system.requires" +build-dir = "build/{wheel_tag}" +wheel.py-api = "cp312" # Build all Python currently supported versions +cmake.version = ">=3.15" +cmake.build-type = "Release" + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.regex" +input = "src/compas_libigl/__init__.py" + +[tool.scikit-build.cmake.define] +CMAKE_POLICY_DEFAULT_CMP0135 = "NEW" + +# ============================================================================ +# cibuildwheel configuration +# ============================================================================ + +[tool.cibuildwheel] +build-verbosity = 3 +test-requires = ["numpy", "compas", "pytest", "build"] +test-command = "pip install numpy compas && pip list && pytest {project}/tests" +build-frontend = "pip" +manylinux-x86_64-image = "manylinux2014" +skip = ["*_i686", "*-musllinux_*", "*-win32", "pp*"] +macos.environment.MACOSX_DEPLOYMENT_TARGET = "11.00" +macos.archs = ["x86_64", "arm64"] + +# ============================================================================ +# bumpversion configuration # ============================================================================ [tool.bumpversion] @@ -88,7 +106,7 @@ search = "Unreleased" replace = "[{new_version}] {now:%Y-%m-%d}" # ============================================================================ -# replace setup.cfg +# code style configuration # ============================================================================ [tool.black] diff --git a/requirements-dev.txt b/requirements-dev.txt index 044b3ff..ea2de21 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,9 @@ ruff sphinx_compas2_theme twine wheel +line_profiler +scikit-build-core[pyproject] >=0.10 +twine +wheel +setuptools +cibuildwheel==2.23.1 \ No newline at end of file diff --git a/scripts/temp/_anisotroremesh.py b/scripts/temp/_anisotroremesh.py deleted file mode 100644 index d8e2057..0000000 --- a/scripts/temp/_anisotroremesh.py +++ /dev/null @@ -1 +0,0 @@ -# https://libigl.github.io/tutorial/#anisotropic-remeshing diff --git a/scripts/temp/_mixedintquadrang.py b/scripts/temp/_mixedintquadrang.py deleted file mode 100644 index 7671c33..0000000 --- a/scripts/temp/_mixedintquadrang.py +++ /dev/null @@ -1 +0,0 @@ -# https://libigl.github.io/tutorial/#global-seamless-integer-grid-parametrization diff --git a/scripts/temp/_nrosy.py b/scripts/temp/_nrosy.py deleted file mode 100644 index ff15e91..0000000 --- a/scripts/temp/_nrosy.py +++ /dev/null @@ -1 +0,0 @@ -# https://libigl.github.io/tutorial/#n-rotationally-symmetric-tangent-fields diff --git a/scripts/temp/_solvers.py b/scripts/temp/_solvers.py deleted file mode 100644 index 4eb4d53..0000000 --- a/scripts/temp/_solvers.py +++ /dev/null @@ -1,3 +0,0 @@ -# https://libigl.github.io/tutorial/#quadratic-energy-minimization -# https://libigl.github.io/tutorial/#linear-equality-constraints -# https://libigl.github.io/tutorial/#quadratic-programming diff --git a/setup.py b/setup.py deleted file mode 100644 index ce35510..0000000 --- a/setup.py +++ /dev/null @@ -1,102 +0,0 @@ -import io -from os import path -import os -import re -import sys -import platform -import subprocess - -from setuptools import setup, Extension -from setuptools.command.build_ext import build_ext -from distutils.version import LooseVersion - -here = path.abspath(path.dirname(__file__)) - - -def read(*names, **kwargs): - return io.open( - path.join(here, *names), encoding=kwargs.get("encoding", "utf8") - ).read() - - -class CMakeExtension(Extension): - def __init__(self, name, sourcedir=""): - Extension.__init__(self, name, sources=[]) - self.sourcedir = os.path.abspath(sourcedir) - - -class CMakeBuild(build_ext): - def run(self): - try: - out = subprocess.check_output(["cmake", "--version"]) - except OSError: - raise RuntimeError( - "CMake must be installed to build the following extensions: " - + ", ".join(e.name for e in self.extensions) - ) - - if platform.system() == "Windows": - cmake_version = LooseVersion( - re.search(r"version\s*([\d.]+)", out.decode()).group(1) - ) - if cmake_version < "3.1.0": - raise RuntimeError("CMake >= 3.1.0 is required on Windows") - - for ext in self.extensions: - self.build_extension(ext) - - def build_extension(self, ext): - extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name))) - cmake_args = [ - "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=" + extdir, - "-DPYTHON_EXECUTABLE=" + sys.executable, - ] - - cfg = "Debug" if self.debug else "Release" - build_args = ["--config", cfg] - - if platform.system() == "Windows": - cmake_args += [ - "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(cfg.upper(), extdir) - ] - if sys.maxsize > 2**32: - cmake_args += ["-A", "x64"] - # build_args += ['--', '/m'] - else: - # # For MacOS. - # # During compiling stage, the python module always links to a temporary generated library which is going to be destroyed. - # # Then importing the final installed module will return a link error - # # The following commands will force the module to look up the its dynmaic linked library in the same folder - # cmake_args += [ - # '-DCMAKE_INSTALL_RPATH=@loader_path', - # '-DCMAKE_BUILD_WITH_INSTALL_RPATH:BOOL=ON', - # '-DCMAKE_INSTALL_RPATH_USE_LINK_PATH:BOOL=OFF'] - - cmake_args += ["-DCMAKE_BUILD_TYPE=" + cfg] - build_args += ["--", "-j2"] - - env = os.environ.copy() - env["CXXFLAGS"] = '{} -DVERSION_INFO=\\"{}\\"'.format( - env.get("CXXFLAGS", ""), self.distribution.get_version() - ) - if not os.path.exists(self.build_temp): - os.makedirs(self.build_temp) - subprocess.check_call( - ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp, env=env - ) - subprocess.check_call( - ["cmake", "--build", "."] + build_args, cwd=self.build_temp - ) - - -long_description = read("README.md") -requirements = read("requirements.txt").split("\n") -optional_requirements = {} - - -setup( - name="compas_libigl", - packages=["compas_libigl"], - ext_modules=[CMakeExtension("compas_libigl")], - cmdclass=dict(build_ext=CMakeBuild), -) diff --git a/src/boundaries.cpp b/src/boundaries.cpp new file mode 100644 index 0000000..15c334e --- /dev/null +++ b/src/boundaries.cpp @@ -0,0 +1,15 @@ +#include "boundaries.hpp" + +std::vector> trimesh_boundaries( Eigen::Ref F) { + std::vector> L; + igl::boundary_loop(F, L); + return L; +} + +NB_MODULE(_boundaries, m) { + m.def( + "trimesh_boundaries", + &trimesh_boundaries, + "Compute list of ordered boundary loops for a manifold mesh.", + "F"_a); +} \ No newline at end of file diff --git a/src/boundaries.hpp b/src/boundaries.hpp new file mode 100644 index 0000000..17d97c5 --- /dev/null +++ b/src/boundaries.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "compas.hpp" +#include + +/** + * Compute all ordered boundary loops of a manifold triangle mesh. + * + * @param F The face matrix of the triangle mesh (n x 3). + * @return A vector of vectors containing vertex indices for each boundary loop. + * + * @note The input mesh should be manifold for correct results. + */ + std::vector> trimesh_boundaries(Eigen::Ref F); \ No newline at end of file diff --git a/src/compas.hpp b/src/compas.hpp new file mode 100644 index 0000000..923db79 --- /dev/null +++ b/src/compas.hpp @@ -0,0 +1,30 @@ +#pragma once + +// Prevent Windows.h from defining min/max macros +#define NOMINMAX + +// STL includes +#include +#include +#include +#include +#include +#include +#include + +// Nanobind includes +#include +#include +#include +#include +#include +#include + +namespace compas { + using RowMatrixXd = Eigen::Matrix; + using RowMatrixXi = Eigen::Matrix; +} + +namespace nb = nanobind; + +using namespace nb::literals; \ No newline at end of file diff --git a/src/compas_libigl/__init__.py b/src/compas_libigl/__init__.py index f433f5d..d323c1b 100644 --- a/src/compas_libigl/__init__.py +++ b/src/compas_libigl/__init__.py @@ -1,21 +1,21 @@ import os import compas - +from ._nanobind import add, __doc__ from .boundaries import trimesh_boundaries from .curvature import trimesh_gaussian_curvature, trimesh_principal_curvature -from .geodistance import trimesh_geodistance +from .geodistance import trimesh_geodistance, trimesh_geodistance_multiple from .intersections import intersection_ray_mesh, intersection_rays_mesh from .isolines import trimesh_isolines, groupsort_isolines from .massmatrix import trimesh_massmatrix -from .meshing import trimesh_remesh_along_isoline from .parametrisation import trimesh_harmonic, trimesh_lscm from .planarize import quadmesh_planarize +from .meshing import trimesh_remesh_along_isoline, trimesh_remesh_along_isolines -__author__ = ["tom van mele"] +__author__ = ["tom van mele", "petras vestartas"] __copyright__ = "Block Research Group - ETH Zurich" __license__ = "Mozilla Public License Version 2.0" -__email__ = "van.mele@arch.ethz.ch" +__email__ = "van.mele@arch.ethz.ch, petrasvestartas@gmail.com" __version__ = "0.3.1" @@ -60,7 +60,7 @@ def get(filename): -------- >>> import compas_libigl as igl >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(igl.get('bunny.off')) + >>> mesh = Mesh.from_off(igl.get("bunny.off")) """ filename = filename.strip("/") @@ -82,14 +82,15 @@ def get_armadillo(): __all_plugins__ = [ + "compas_libigl._nanobindcompas_libigl._boundaries", "compas_libigl.curvature", "compas_libigl.geodistance", "compas_libigl.intersections", "compas_libigl.isolines", "compas_libigl.massmatrix", - "compas_libigl.meshing", "compas_libigl.parametrisation", "compas_libigl.planarize", + "compas_libigl.meshing", ] __all__ = [ @@ -97,6 +98,8 @@ def get_armadillo(): "DATA", "DOCS", "TEMP", + "add", + "__doc__", "get", "get_beetle", "get_armadillo", @@ -104,13 +107,15 @@ def get_armadillo(): "trimesh_gaussian_curvature", "trimesh_principal_curvature", "trimesh_geodistance", + "trimesh_geodistance_multiple", "intersection_ray_mesh", "intersection_rays_mesh", "trimesh_isolines", "groupsort_isolines", "trimesh_massmatrix", - "trimesh_remesh_along_isoline", "trimesh_harmonic", "trimesh_lscm", "quadmesh_planarize", + "trimesh_remesh_along_isoline", + "trimesh_remesh_along_isolines", ] diff --git a/src/compas_libigl/boundaries.py b/src/compas_libigl/boundaries.py new file mode 100644 index 0000000..889668b --- /dev/null +++ b/src/compas_libigl/boundaries.py @@ -0,0 +1,33 @@ +import numpy as np + +from compas_libigl import _boundaries +from compas_libigl._types_std import VectorVectorInt + + +def trimesh_boundaries(M): + """Compute all ordered boundary loops of a manifold triangle mesh. + + Uses libigl to extract and order the boundary loops of a triangle mesh. + The input mesh must be manifold for correct results. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + The vertices should be 3D points, and faces should be triangles. + + Returns + ------- + list[list[int]] + The ordered boundary loops of the triangle mesh. + Each loop is a sequence of vertex indices defining a closed boundary. + + Notes + ----- + The input mesh should be manifold. + Non-manifold meshes may produce unexpected or incorrect results. + """ + V, F = M + F = np.asarray(F, dtype=np.int32) + result: VectorVectorInt = _boundaries.trimesh_boundaries(F) + return [list(loop) for loop in result] diff --git a/src/compas_libigl/boundaries/__init__.py b/src/compas_libigl/boundaries/__init__.py deleted file mode 100644 index e6ef0be..0000000 --- a/src/compas_libigl/boundaries/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -import numpy as np -from compas_libigl_boundaries import ( - trimesh_boundaries as _trimesh_boundaries, -) - - -def trimesh_boundaries(M): - """Compute all (ordered) boundary loops of a manifold triangle mesh. - - Parameters - ---------- - M : (list, list) - A mesh represented by a list of vertices and a list of faces. - - Returns - ------- - array - The ordered boundary loops of the triangle mesh. - Each loop is a sequence of vertex indices. - - Notes - ----- - The input mesh should be manifold. - - Examples - -------- - >>> import compas - >>> import compas_libigl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(compas.get('tubemesh.off')) - >>> mesh.quads_to_triangles() - >>> M = mesh.to_vertices_and_faces() - >>> boundaries = compas_libigl.trimesh_boundaries(M) - >>> len(boundaries) == 1 - True - - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - return _trimesh_boundaries(V, F) diff --git a/src/compas_libigl/boundaries/boundaries.cpp b/src/compas_libigl/boundaries/boundaries.cpp deleted file mode 100644 index 998019a..0000000 --- a/src/compas_libigl/boundaries/boundaries.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include -#include -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - - -std::vector> -trimesh_boundaries( - RowMatrixXd V, - RowMatrixXi F) -{ - std::vector> L; - - igl::boundary_loop(F, L); - - return L; -} - - -PYBIND11_MODULE(compas_libigl_boundaries, m) { - m.def( - "trimesh_boundaries", - &trimesh_boundaries, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); -} diff --git a/src/compas_libigl/curvature.py b/src/compas_libigl/curvature.py new file mode 100644 index 0000000..c000a73 --- /dev/null +++ b/src/compas_libigl/curvature.py @@ -0,0 +1,56 @@ +import numpy as np + +from compas_libigl import _curvature + + +def trimesh_gaussian_curvature(M): + """Compute the discrete gaussian curvature of a triangle mesh. + + Calculates the Gaussian curvature at each vertex of a triangle mesh + using the angle defect method. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + The vertices should be 3D points, and faces should be triangles. + + Returns + ------- + list[float] + The gaussian curvature values per vertex. + Positive values indicate elliptic points, negative values indicate hyperbolic points, + and zero values indicate parabolic or flat points. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + return _curvature.trimesh_gaussian_curvature(V, F) + + +def trimesh_principal_curvature(M): + """Compute the principal curvatures and directions of a triangle mesh. + + Calculates both the principal curvature values and their corresponding + directions at each vertex of the mesh. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + The vertices should be 3D points, and faces should be triangles. + + Returns + ------- + tuple[list[list[float]], list[list[float]], list[float], list[float]] + A tuple containing: + * PD1: Principal direction 1 per vertex (normalized vectors) + * PD2: Principal direction 2 per vertex (normalized vectors) + * PV1: Principal curvature value 1 per vertex (maximum curvature) + * PV2: Principal curvature value 2 per vertex (minimum curvature) + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + PD1, PD2, PV1, PV2 = _curvature.trimesh_principal_curvature(V, F) + return PD1.tolist(), PD2.tolist(), PV1.tolist(), PV2.tolist() diff --git a/src/compas_libigl/curvature/__init__.py b/src/compas_libigl/curvature/__init__.py deleted file mode 100644 index e0de6aa..0000000 --- a/src/compas_libigl/curvature/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -import numpy as np -from compas_libigl_curvature import ( - trimesh_gaussian_curvature as _gaussian, -) -from compas.plugins import plugin - - -@plugin(category="trimesh") -def trimesh_gaussian_curvature(M): - """Compute the discrete gaussian curvature of a triangle mesh. - - Parameters - ---------- - M : (list, list) - A mesh represented by a list of vertices and a list of faces. - - Returns - ------- - array - The discrete gaussian curvature per vertex. - - Examples - -------- - >>> import compas - >>> import compas_libigl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(compas.get('tubemesh.off')) - >>> mesh.quads_to_triangles() - >>> M = mesh.to_vertices_and_faces() - >>> curvature = compas_libigl.trimesh_gaussian_curvature(M) - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - return _gaussian(V, F) - - -def trimesh_principal_curvature(): - pass diff --git a/src/compas_libigl/curvature/curvature.cpp b/src/compas_libigl/curvature/curvature.cpp deleted file mode 100644 index 102b974..0000000 --- a/src/compas_libigl/curvature/curvature.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - - -Eigen::VectorXd -trimesh_gaussian_curvature( - RowMatrixXd V, - RowMatrixXi F) -{ - Eigen::VectorXd C; - igl::gaussian_curvature(V, F, C); - - return C; -} - - -std::tuple< - Eigen::MatrixXd, - Eigen::MatrixXd, - Eigen::VectorXd, - Eigen::VectorXd> -trimesh_principal_curvature( - RowMatrixXd V, - RowMatrixXi F) -{ - Eigen::MatrixXd PD1; - Eigen::MatrixXd PD2; - Eigen::VectorXd PV1; - Eigen::VectorXd PV2; - - std::vector bad_vertices; - - igl::principal_curvature(V, F, PD1, PD2, PV1, PV2, bad_vertices); - - std::tuple< - Eigen::MatrixXd, - Eigen::MatrixXd, - Eigen::VectorXd, - Eigen::VectorXd> result= std::make_tuple(PD1, PD2, PV1, PV2); - - return result; -} - - -PYBIND11_MODULE(compas_libigl_curvature, m) { - m.def( - "trimesh_gaussian_curvature", - &trimesh_gaussian_curvature, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); - - m.def( - "trimesh_principal_curvature", - &trimesh_principal_curvature, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); -} diff --git a/src/compas_libigl/geodistance.py b/src/compas_libigl/geodistance.py new file mode 100644 index 0000000..38a4eb6 --- /dev/null +++ b/src/compas_libigl/geodistance.py @@ -0,0 +1,81 @@ +import numpy as np +from compas.plugins import plugin + +from compas_libigl import _geodistance + + +@plugin(category="trimesh") +def trimesh_geodistance(M, source, method="exact"): + """Compute the geodesic distance from a source point to all vertices. + + Calculates the geodesic distance from a single source vertex to all other + vertices in the mesh using either exact or heat method. + + Parameters + ---------- + M : tuple[:class:`list`, :class:`list`] + A mesh represented by a list of vertices and a list of faces. + The vertices should be 3D points, and faces should be triangles. + source : int + The index of the source vertex. + method : str, optional + The method to use for geodesic distance computation. + Options: + * 'exact': Use exact geodesic algorithm + * 'heat': Use heat method (faster but approximate) + Default is 'exact'. + + Returns + ------- + list[float] + The geodesic distances from the source to all vertices. + + Raises + ------ + NotImplementedError + If method is not one of {'exact', 'heat'}. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + # Extract single integer from source array if needed + source = int(source) # Ensure it's a scalar + return _geodistance.trimesh_geodistance(V, F, source, method) + + +@plugin(category="trimesh") +def trimesh_geodistance_multiple(M, sources, method="exact"): + """Compute the geodesic distance from multiple source points. + + Calculates the minimum geodesic distance from any of the source vertices + to all other vertices in the mesh. + + Parameters + ---------- + M : tuple[:class:`list`, :class:`list`] + A mesh represented by a list of vertices and a list of faces. + The vertices should be 3D points, and faces should be triangles. + sources : list[int] + The indices of the source vertices. + method : str, optional + The method to use for geodesic distance computation. + Options: + * 'exact': Use exact geodesic algorithm + * 'heat': Use heat method (faster but approximate) + Default is 'exact'. + + Returns + ------- + list[float] + The minimum geodesic distances from any source to all vertices. + + Raises + ------ + NotImplementedError + If method is not one of {'exact', 'heat'}. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + sources = np.asarray(sources, dtype=np.int32) + return _geodistance.trimesh_geodistance_multiple(V, F, sources, method) diff --git a/src/compas_libigl/geodistance/__init__.py b/src/compas_libigl/geodistance/__init__.py deleted file mode 100644 index 19d47ea..0000000 --- a/src/compas_libigl/geodistance/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -import numpy as np -from compas_libigl_geodistance import ( - trimesh_geodistance_exact as _exact, - trimesh_geodistance_heat as _heat, -) -from compas.plugins import plugin - - -@plugin(category="trimesh") -def trimesh_geodistance(M, source, method="exact"): - """Compute the geodesic distance from every vertex of the mesh to a source vertex. - - Parameters - ---------- - M : (list, list) - A mesh represented by a list of vertices and a list of faces. - source : int - The index of the vertex from where the geodesic distances should be calculated. - method : {'exact', 'heat'} - The method for calculating the distances. - Default is `'exact'`. - - Returns - ------- - list of float - A list of geodesic distances from the source vertex. - - Raises - ------ - NotImplementedError - If ``method`` is not one of ``{'exact', 'heat'}``. - - Examples - -------- - >>> - - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - if method == "exact": - return _exact(V, F, source) - if method == "heat": - return _heat(V, F, source) - raise NotImplementedError diff --git a/src/compas_libigl/geodistance/geodistance.cpp b/src/compas_libigl/geodistance/geodistance.cpp deleted file mode 100644 index 80c2d9a..0000000 --- a/src/compas_libigl/geodistance/geodistance.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include -#include -#include -#include -#include - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - -namespace py = pybind11; - - -Eigen::VectorXd -trimesh_geodistance_exact( - RowMatrixXd V, - RowMatrixXi F, - int vid) -{ - Eigen::VectorXd D; - Eigen::VectorXi VS, FS, VT, FT; - - VS.resize(1); - VS << vid; - - VT.setLinSpaced(V.rows(), 0, V.rows() - 1); - - igl::exact_geodesic(V, F, VS, FS, VT, FT, D); - - return D; -} - - -Eigen::VectorXd -trimesh_geodistance_heat( - RowMatrixXd V, - RowMatrixXi F, - int vid) -{ - Eigen::VectorXi gamma; - gamma.resize(1); - gamma << vid; - - igl::HeatGeodesicsData data; - double t = std::pow(igl::avg_edge_length(V, F), 2); - igl::heat_geodesics_precompute(V, F, t, data); - - Eigen::VectorXd D = Eigen::VectorXd::Zero(data.Grad.cols()); - D(vid) = 1; - - igl::heat_geodesics_solve(data, gamma, D); - - return D; -} - - -PYBIND11_MODULE(compas_libigl_geodistance, m) { - m.def( - "trimesh_geodistance_exact", - &trimesh_geodistance_exact, - py::arg("V").noconvert(), - py::arg("F").noconvert(), - py::arg("vid") - ); - - m.def( - "trimesh_geodistance_heat", - &trimesh_geodistance_heat, - py::arg("V").noconvert(), - py::arg("F").noconvert(), - py::arg("vid") - ); -} diff --git a/src/compas_libigl/intersections/__init__.py b/src/compas_libigl/intersections.py similarity index 53% rename from src/compas_libigl/intersections/__init__.py rename to src/compas_libigl/intersections.py index 0fffe58..4511c5c 100644 --- a/src/compas_libigl/intersections/__init__.py +++ b/src/compas_libigl/intersections.py @@ -1,10 +1,8 @@ import numpy as np -from compas_libigl_intersections import ( - intersection_ray_mesh as _intersection_ray_mesh, - intersection_rays_mesh as _intersection_rays_mesh, -) from compas.plugins import plugin +from compas_libigl import _intersections + @plugin(category="intersections") def intersection_ray_mesh(ray, M): @@ -12,14 +10,14 @@ def intersection_ray_mesh(ray, M): Parameters ---------- - ray : tuple of point and vector + ray : tuple[list[float], list[float]] A ray represented by a point and a direction vector. - M : (list, list) + M : tuple[list[list[float]], list[list[int]]] A mesh represented by a list of vertices and a list of faces. Returns ------- - array + list[tuple[int, float, float, float]] The array contains a tuple per intersection of the ray with the mesh. Each tuple contains: @@ -27,26 +25,6 @@ def intersection_ray_mesh(ray, M): 1. the u coordinate of the intersection in the barycentric coordinates of the face 2. the u coordinate of the intersection in the barycentric coordinates of the face 3. the distance between the ray origin and the hit - - Examples - -------- - >>> import compas - >>> import compas_libigl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(compas.get('tubemesh.off')) - >>> mesh.quads_to_triangles() - >>> M = mesh.to_vertices_and_faces() - >>> centroid = mesh.centroid() - >>> ray = [centroid[0], centroid[1], 0], [0, 0, 1.0] - >>> hits = compas_libigl.intersection_ray_mesh(ray, M) - >>> len(hits) == 1 - True - - To compute the actual intersection point, do - - >>> from compas.geometry import add_vectors, scale_vector - >>> point = add_vectors(ray[0], scale_vector(ray[1], hits[0][3])) - """ point, vector = ray vertices, faces = M @@ -54,14 +32,34 @@ def intersection_ray_mesh(ray, M): D = np.asarray(vector, dtype=np.float64) V = np.asarray(vertices, dtype=np.float64) F = np.asarray(faces, dtype=np.int32) - return _intersection_ray_mesh(P, D, V, F) + return _intersections.intersection_ray_mesh(P, D, V, F) def intersection_rays_mesh(rays, M): + """Compute the intersection(s) between multiple rays and a mesh. + + Parameters + ---------- + rays : list[tuple[list[float], list[float]]] + List of rays, each represented by a point and a direction vector. + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + + Returns + ------- + list[list[tuple[int, float, float, float]]] + List of intersection results, one per ray. + Each intersection result contains tuples with: + + 0. the index of the intersected face + 1. the u coordinate of the intersection in the barycentric coordinates of the face + 2. the u coordinate of the intersection in the barycentric coordinates of the face + 3. the distance between the ray origin and the hit + """ points, vectors = zip(*rays) vertices, faces = M P = np.asarray(points, dtype=np.float64) D = np.asarray(vectors, dtype=np.float64) V = np.asarray(vertices, dtype=np.float64) F = np.asarray(faces, dtype=np.int32) - return _intersection_rays_mesh(P, D, V, F) + return _intersections.intersection_rays_mesh(P, D, V, F) diff --git a/src/compas_libigl/intersections/intersections.cpp b/src/compas_libigl/intersections/intersections.cpp deleted file mode 100644 index c41b08c..0000000 --- a/src/compas_libigl/intersections/intersections.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include -#include -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - -using Hit = std::tuple; -using HitList = std::vector; - - -HitList -intersection_ray_mesh( - Eigen::Vector3d point, - Eigen::Vector3d direction, - RowMatrixXd V, - RowMatrixXi F) -{ - HitList hits; - std::vector igl_hits; - - bool result = igl::ray_mesh_intersect(point, direction, V, F, igl_hits); - - if (result) { - for (const auto& hit : igl_hits) { - hits.push_back(std::make_tuple(hit.id, hit.u, hit.v, hit.t)); - } - } - - return hits; -} - - -std::vector -intersection_rays_mesh( - RowMatrixXd points, - RowMatrixXd directions, - RowMatrixXd V, - RowMatrixXi F) -{ - std::vector hits_per_ray; - - int r = points.rows(); - - for (int i = 0; i < r; i++){ - std::vector igl_hits; - bool result = igl::ray_mesh_intersect(points.row(i), directions.row(i), V, F, igl_hits); - - HitList hits; - if (result) { - for (const auto& hit : igl_hits) { - hits.push_back(std::make_tuple(hit.id, hit.u, hit.v, hit.t)); - } - } - hits_per_ray.push_back(hits); - } - - return hits_per_ray; -} - - -PYBIND11_MODULE(compas_libigl_intersections, m) { - m.def( - "intersection_ray_mesh", - &intersection_ray_mesh, - py::arg("point"), - py::arg("direction"), - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); - - m.def( - "intersection_rays_mesh", - &intersection_rays_mesh, - py::arg("points"), - py::arg("directions"), - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); -} diff --git a/src/compas_libigl/isolines.py b/src/compas_libigl/isolines.py new file mode 100644 index 0000000..2c38139 --- /dev/null +++ b/src/compas_libigl/isolines.py @@ -0,0 +1,138 @@ +import numpy as np +from compas.geometry import Polyline +from compas.plugins import plugin + +from compas_libigl import _isolines + + +@plugin(category="trimesh") +def trimesh_isolines(M, scalars, isovalues): + """ + Compute isolines on a triangle mesh. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] | :class:`compas.datastructures.Mesh` + A mesh represented by a list of vertices and a list of faces, + or by a COMPAS mesh object. + scalars : list[float] + A list of scalar values, one per vertex of the mesh. + isovalues : list[float] + The values at which to compute the isolines. + Each value should be within the range of the scalar field. + + Returns + ------- + tuple[list[list[float]], list[list[int]], list[int]] + A tuple containing: + + * The coordinates of the isoline vertices + * The edges between these vertices forming the isolines + * An index per edge indicating to which isoline it belongs + + Notes + ----- + The input mesh should be triangulated for accurate results. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + S = np.asarray(scalars, dtype=np.float64) + + # Create evenly spaced isovalues + ISO = np.asarray(isovalues, dtype=np.float64) + + # Note: C++ function expects (V, F, isovalues, scalars) + iso = _isolines.trimesh_isolines(V, F, S, ISO) + + return iso[0], iso[1], iso[2] # Return vertices and edges only + + +def groupsort_isolines(vertices, edges, indices): + """ + Group and sort isoline edges into continuous polylines. + + Parameters + ---------- + vertices : list[list[float]] + The coordinates of the isoline vertices. + edges : list[list[int]] + The edges between vertices forming the isolines. + indices : list[int] + An index per edge indicating to which isoline it belongs. + + Returns + ------- + list[list[:class:`compas.geometry.Polyline`]] + A list of polyline groups, where each group corresponds to an isoline level. + Each polyline represents a continuous segment of an isoline. + + Notes + ----- + The function attempts to create the minimum number of polylines by connecting + edges that share vertices and have the same isovalue. + """ + # Group edges by their index value + edge_groups = {} + for i, edge in enumerate(edges): + idx = indices[i].item() # Convert numpy array element to scalar using item() + if idx not in edge_groups: + edge_groups[idx] = [] + edge_groups[idx].append(edge.tolist()) + + # Process each group into polylines + polyline_groups = [] + for idx, edges in edge_groups.items(): + # Convert edges to list of vertex pairs for processing + remaining_edges = edges.copy() + polylines = [] + + while remaining_edges: + # Start a new polyline + current_edge = remaining_edges.pop(0) + current_vertices = [vertices[current_edge[0]], vertices[current_edge[1]]] + start_vertex_idx = current_edge[0] + end_vertex_idx = current_edge[1] + + # Try to extend the polyline + found = True + while found: + found = False + for i, edge in enumerate(remaining_edges): + v0, v1 = edge + + # Check if edge connects to end of current polyline + if v0 == end_vertex_idx: + current_vertices.append(vertices[v1]) + end_vertex_idx = v1 + remaining_edges.pop(i) + found = True + break + # Check if edge connects to start of current polyline + elif v1 == start_vertex_idx: + current_vertices.insert(0, vertices[v0]) + start_vertex_idx = v0 + remaining_edges.pop(i) + found = True + break + # Check if edge needs to be reversed to connect to end + elif v1 == end_vertex_idx: + current_vertices.append(vertices[v0]) + end_vertex_idx = v0 + remaining_edges.pop(i) + found = True + break + # Check if edge needs to be reversed to connect to start + elif v0 == start_vertex_idx: + current_vertices.insert(0, vertices[v1]) + start_vertex_idx = v1 + remaining_edges.pop(i) + found = True + break + + # Add the completed polyline + polylines.append(Polyline(current_vertices)) + + polyline_groups.append(polylines) + + return polyline_groups diff --git a/src/compas_libigl/isolines/__init__.py b/src/compas_libigl/isolines/__init__.py deleted file mode 100644 index cea8664..0000000 --- a/src/compas_libigl/isolines/__init__.py +++ /dev/null @@ -1,115 +0,0 @@ -import numpy as np -from itertools import groupby -from compas_libigl_isolines import trimesh_isolines as _trimesh_isolines -from compas.plugins import plugin - - -@plugin(category="trimesh") -def trimesh_isolines(M, S, N=50): - """Compute isolines on a triangle mesh using a scalarfield of data points - assigned to its vertices. - - Parameters - ---------- - M : tuple or :class:`compas.datastructures.Mesh` - A mesh represented by a list of vertices and a list of faces - or by a COMPAS mesh object. - S : list - A list of scalars. - N : int, optional - The number of isolines. - Default is ``50``. - - Returns - ------- - tuple - 0. The coordinates of the polyline segments representing the isolines. - 1. The segments of the polylines. - - Examples - -------- - >>> import compas - >>> import compas_libigl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(compas.get('tubemesh.off')) - >>> mesh.quads_to_triangles() - >>> M = mesh.to_vertices_and_faces() - >>> scalars = mesh.vertices_attribute('z') - >>> vertices, edges = compas_libigl.trimesh_isolines(M, scalars, 50) - - To convert the vertices and edges to sets of isolines, use :func:`groupsort_isolines` - - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - S = np.asarray(S, dtype=np.float64) - # return the isolines as a tuple - # not a struct - iso = _trimesh_isolines(V, F, S, N) - return iso.vertices, iso.edges - - -def groupsort_isolines(vertices, edges): - """Group isolines edges per value level and sort edges into paths. - - Parameters - ---------- - vertices : list - Isoline vertices. - edges : list - Isoline vertex pairs. - - Returns - ------- - list - Every item in the list is a tuple - containing a level value and level edges - sorted into continuous paths. - - Examples - -------- - >>> - """ - levels = groupby( - sorted(edges, key=lambda edge: vertices[edge[0]][2]), - key=lambda edge: round(vertices[edge[0]][2], 3), - ) - isolines = [] - for value, edges in levels: - paths = [] - edges = [edge.tolist() for edge in edges] - edge = edges.pop() - paths.append([edge]) - while edges: - for edge in edges: - found = False - for path in paths: - a = path[0][0] - z = path[-1][1] - u, v = edge - if u == z: - path.append([u, v]) - edges.remove(edge) - found = True - break - if v == z: - path.append([v, u]) - edges.remove(edge) - found = True - break - if v == a: - path.insert(0, [u, v]) - edges.remove(edge) - found = True - break - if u == a: - path.insert(0, [v, u]) - edges.remove(edge) - found = True - break - if not found: - paths.append([edge]) - edges.remove(edge) - isolines.append((value, paths)) - return isolines diff --git a/src/compas_libigl/isolines/isolines.cpp b/src/compas_libigl/isolines/isolines.cpp deleted file mode 100644 index c2e8264..0000000 --- a/src/compas_libigl/isolines/isolines.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - - -struct Isolines { - RowMatrixXd vertices; - RowMatrixXi edges; -}; - - -Isolines -trimesh_isolines( - RowMatrixXd V, - RowMatrixXi F, - Eigen::VectorXd z, - int n) -{ - RowMatrixXd Vi; - RowMatrixXi Ei; - - igl::isolines(V, F, z, n, Vi, Ei); - - Isolines iso; - - iso.vertices = Vi; - iso.edges = Ei; - - return iso; -} - - -PYBIND11_MODULE(compas_libigl_isolines, m) { - m.def( - "trimesh_isolines", - &trimesh_isolines, - py::arg("V").noconvert(), - py::arg("F").noconvert(), - py::arg("z"), - py::arg("n") - ); - - py::class_(m, "Isolines") - .def_readonly("vertices", &Isolines::vertices) - .def_readonly("edges", &Isolines::edges); -} diff --git a/src/compas_libigl/massmatrix.py b/src/compas_libigl/massmatrix.py new file mode 100644 index 0000000..c559067 --- /dev/null +++ b/src/compas_libigl/massmatrix.py @@ -0,0 +1,25 @@ +import numpy as np +from compas.plugins import plugin + +from compas_libigl import _massmatrix + + +@plugin(category="trimesh") +def trimesh_massmatrix(M): + """Compute the mass matrix of a triangle mesh. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] | :class:`compas.datastructures.Mesh` + A mesh represented by a list of vertices and a list of faces + or by a COMPAS mesh object. + + Returns + ------- + scipy.sparse.csc_matrix + The mass matrix in sparse format. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + return _massmatrix.trimesh_massmatrix(V, F) diff --git a/src/compas_libigl/massmatrix/__init__.py b/src/compas_libigl/massmatrix/__init__.py deleted file mode 100644 index ffa3af3..0000000 --- a/src/compas_libigl/massmatrix/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -import numpy as np -from compas_libigl_massmatrix import ( - trimesh_massmatrix as _trimesh_massmatrix, -) -from compas.plugins import plugin - - -@plugin(category="trimesh") -def trimesh_massmatrix(M): - """Compute massmatrix on a triangle mesh using a scalarfield of data points - assigned to its vertices. - - Parameters - ---------- - M : tuple or :class:`compas.datastructures.Mesh` - A mesh represented by a list of vertices and a list of faces - or by a COMPAS mesh object. - - Returns - ------- - array - The mass per vertex. - - Examples - -------- - >>> import compas - >>> import compas_libigl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(compas.get('tubemesh.off')) - >>> mesh.quads_to_triangles() - >>> M = mesh.to_vertices_and_faces() - >>> mass = compas_libigl.trimesh_massmatrix(M) - - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - return _trimesh_massmatrix(V, F) diff --git a/src/compas_libigl/massmatrix/massmatrix.cpp b/src/compas_libigl/massmatrix/massmatrix.cpp deleted file mode 100644 index 6909426..0000000 --- a/src/compas_libigl/massmatrix/massmatrix.cpp +++ /dev/null @@ -1,34 +0,0 @@ -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - - -Eigen::VectorXd -trimesh_massmatrix( - RowMatrixXd V, - RowMatrixXi F) -{ - Eigen::SparseMatrix M; - igl::massmatrix(V, F, igl::MASSMATRIX_TYPE_VORONOI, M); - - // std::cout << M << std::endl; - - Eigen::VectorXd mass = M.diagonal(); - - return mass; -} - - -PYBIND11_MODULE(compas_libigl_massmatrix, m) { - m.def( - "trimesh_massmatrix", - &trimesh_massmatrix, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); -} diff --git a/src/compas_libigl/meshing.py b/src/compas_libigl/meshing.py new file mode 100644 index 0000000..182d6b4 --- /dev/null +++ b/src/compas_libigl/meshing.py @@ -0,0 +1,65 @@ +import numpy as np +from compas.plugins import plugin + +from compas_libigl import _meshing + + +@plugin(category="trimesh") +def trimesh_remesh_along_isoline(M, scalars, isovalue): + """Remesh a triangle mesh along an isoline. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + scalars : list[float] + A scalar value per vertex. + isovalue : float + The value at which to compute the isoline. + + Returns + ------- + tuple[list[list[float]], list[list[int]], list[int]] + A tuple containing + * the vertices of the remeshed mesh, + * the faces of the remeshed mesh, + * labels for the faces of the remeshed mesh. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + S = np.asarray(scalars, dtype=np.float64) + ISO = np.float64(isovalue) + V2, F2, L = _meshing.trimesh_remesh_along_isoline(V, F, S, ISO) + return V2.tolist(), F2.tolist(), L.tolist() + + +@plugin(category="trimesh") +def trimesh_remesh_along_isolines(M, scalars, isovalues): + """Remesh a triangle mesh along multiple isolines. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] + A mesh represented by a list of vertices and a list of faces. + scalars : list[float] + A scalar value per vertex. + isovalues : list[float] + The values at which to compute the isolines. + + Returns + ------- + tuple[list[list[float]], list[list[int]], list[float], list[int]] + A tuple containing + * the vertices of the remeshed mesh, + * the faces of the remeshed mesh, + * scalar values for the vertices of the remeshed mesh, + * labels for the faces of the remeshed mesh. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + S = np.asarray(scalars, dtype=np.float64) + ISO = np.asarray(isovalues, dtype=np.float64) + V2, F2, S2, G2 = _meshing.trimesh_remesh_along_isolines(V, F, S, ISO) + return V2.tolist(), F2.tolist(), S2.tolist(), G2.tolist() diff --git a/src/compas_libigl/meshing/__init__.py b/src/compas_libigl/meshing/__init__.py deleted file mode 100644 index 887fd4f..0000000 --- a/src/compas_libigl/meshing/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -import numpy as np -from compas_libigl_meshing import ( - trimesh_remesh_along_isoline as _trimesh_remesh_along_isolines, -) -from compas.plugins import plugin - - -@plugin(category="trimesh") -def trimesh_remesh_along_isoline(mesh, scalarfield, scalar): - """Remesh a mesh along an isoline of a scalarfield over the vertices. - - Parameters - ---------- - mesh : tuple or :class:`compas.datastructures.Mesh` - A mesh represented by a list of vertices and a list of faces - or a COMPAS mesh object. - scalarfield : list or array of float - A scalar value per vertex of the mesh. - scalar : float - A value within the range of the scalarfield. - - Returns - ------- - tuple - Vertices and faces of the remeshed mesh. - - Examples - -------- - >>> - - """ - V, F = mesh - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - S = np.asarray(scalarfield, dtype=np.float64) - return _trimesh_remesh_along_isolines(V, F, S, scalar) diff --git a/src/compas_libigl/meshing/meshing.cpp b/src/compas_libigl/meshing/meshing.cpp deleted file mode 100644 index 0bd0429..0000000 --- a/src/compas_libigl/meshing/meshing.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include -#include -#include -#include -#include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - - -std::tuple< - RowMatrixXd, - RowMatrixXi, - Eigen::VectorXi> -trimesh_remesh_along_isoline( - RowMatrixXd V1, - RowMatrixXi F1, - Eigen::VectorXd S1, - double s) -{ - RowMatrixXd V2; - RowMatrixXi F2; - Eigen::VectorXd S2; - - Eigen::VectorXi J; - Eigen::SparseMatrix BC; - Eigen::VectorXi L; - - igl::remesh_along_isoline(V1, F1, S1, s, V2, F2, S2, J, BC, L); - - std::tuple< - RowMatrixXd, - RowMatrixXi, - Eigen::VectorXi> result = std::make_tuple(V2, F2, L); - - return result; -} - - -PYBIND11_MODULE(compas_libigl_meshing, m) { - m.def( - "trimesh_remesh_along_isoline", - &trimesh_remesh_along_isoline, - py::arg("V1").noconvert(), - py::arg("F1").noconvert(), - py::arg("S1"), - py::arg("s") - ); -} diff --git a/src/compas_libigl/parametrisation/__init__.py b/src/compas_libigl/parametrisation.py similarity index 53% rename from src/compas_libigl/parametrisation/__init__.py rename to src/compas_libigl/parametrisation.py index 8a1bf76..de7e477 100644 --- a/src/compas_libigl/parametrisation/__init__.py +++ b/src/compas_libigl/parametrisation.py @@ -1,10 +1,8 @@ import numpy as np -from compas_libigl_parametrisation import ( - trimesh_harmonic_map as _harmonic, - trimesh_lscm as _lscm, -) from compas.plugins import plugin +from compas_libigl import _parametrisation + @plugin(category="trimesh") def trimesh_harmonic(M): @@ -12,38 +10,23 @@ def trimesh_harmonic(M): Parameters ---------- - M : tuple + M : tuple[list[list[float]], list[list[int]]] | :class:`compas.datastructures.Mesh` A mesh represented by a list of vertices and a list of faces or by a COMPAS mesh object. Returns ------- - array + list[list[float]] The u, v parameters per vertex. - Examples - -------- - >>> import compas_libigl as igl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(igl.get('camelhead.off')) - >>> mesh_uv = mesh.copy() - >>> mesh_uv.vertices_attribute('z', 0) - >>> M = mesh.to_vertices_and_faces() - >>> uv = igl.trimesh_harmonic(M) - >>> for key in mesh.vertices(): - ... mesh_uv.vertex_attributes(key, 'xy', uv[key]) - ... - >>> - Notes ----- ``camelhead.off`` can be downloaded from https://raw.githubusercontent.com/libigl/libigl-tutorial-data/master/camelhead.off - """ V, F = M V = np.asarray(V, dtype=np.float64) F = np.asarray(F, dtype=np.int32) - return _harmonic(V, F) + return _parametrisation.harmonic(V, F) @plugin(category="trimesh") @@ -52,35 +35,20 @@ def trimesh_lscm(M): Parameters ---------- - M : tuple + M : tuple[list[list[float]], list[list[int]]] | :class:`compas.datastructures.Mesh` A mesh represented by a list of vertices and a list of faces or by a COMPAS mesh object. Returns ------- - array + list[list[float]] The u, v parameters per vertex. - Examples - -------- - >>> import compas_libigl as igl - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_off(igl.get('camelhead.off')) - >>> mesh_uv = mesh.copy() - >>> mesh_uv.vertices_attribute('z', 0) - >>> M = mesh.to_vertices_and_faces() - >>> uv = igl.trimesh_lscm(M) - >>> for key in mesh.vertices(): - ... mesh_uv.vertex_attributes(key, 'xy', uv[key]) - ... - >>> - Notes ----- ``camelhead.off`` can be downloaded from https://raw.githubusercontent.com/libigl/libigl-tutorial-data/master/camelhead.off - """ V, F = M V = np.asarray(V, dtype=np.float64) F = np.asarray(F, dtype=np.int32) - return _lscm(V, F) + return _parametrisation.lscm(V, F) diff --git a/src/compas_libigl/planarize.py b/src/compas_libigl/planarize.py new file mode 100644 index 0000000..7600d52 --- /dev/null +++ b/src/compas_libigl/planarize.py @@ -0,0 +1,39 @@ +import numpy as np +from compas.plugins import plugin + +from compas_libigl import _planarize + + +@plugin(category="quadmesh") +def quadmesh_planarize(M, kmax=500, maxdev=0.005): + """Planarize the faces of a quad mesh. + + Iteratively modify vertex positions to make all quad faces as planar as possible + while minimizing the deviation from the original shape. + + Parameters + ---------- + M : tuple[list[list[float]], list[list[int]]] | :class:`compas.datastructures.Mesh` + A quad mesh represented by a list of vertices and a list of faces, + or by a COMPAS mesh object. + kmax : int, optional + The maximum number of iterations. + Default is ``500``. + maxdev : float, optional + The maximum allowed deviation from planarity. + Default is ``0.005``. + + Returns + ------- + list[list[float]] + The coordinates of the new vertices after planarization. + + Notes + ----- + The input mesh should consist primarily of quad faces for best results. + Non-quad faces may produce unexpected results. + """ + V, F = M + V = np.asarray(V, dtype=np.float64) + F = np.asarray(F, dtype=np.int32) + return _planarize.planarize_quads(V, F, kmax, maxdev) diff --git a/src/compas_libigl/planarize/__init__.py b/src/compas_libigl/planarize/__init__.py deleted file mode 100644 index 14e510c..0000000 --- a/src/compas_libigl/planarize/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -import numpy as np -from compas_libigl_planarize import planarize_quads as _planarize_quads -from compas.plugins import plugin - - -@plugin(category="quadmesh") -def quadmesh_planarize(M, kmax=500, maxdev=0.005): - """Planarize the faces of a quad mesh. - - Parameters - ---------- - M : tuple or :class:`compas.datastructures.Mesh` - A quad mesh represented by a list of vertices and a list of faces - or by a COMPAS mesh object. - kmax : int, optional - The maximum number of iterations. - Default is ``500``. - maxdev : float, optional - The maximum deviation from planar. - Default is ``0.005``. - - Returns - ------- - list - The coordinates of the new vertices. - - Examples - -------- - >>> - - """ - V, F = M - V = np.asarray(V, dtype=np.float64) - F = np.asarray(F, dtype=np.int32) - return _planarize_quads(V, F, kmax, maxdev) diff --git a/src/compas_libigl/planarize/planarize.cpp b/src/compas_libigl/planarize/planarize.cpp deleted file mode 100644 index fa4ac3a..0000000 --- a/src/compas_libigl/planarize/planarize.cpp +++ /dev/null @@ -1,34 +0,0 @@ -#include -#include -#include - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - -namespace py = pybind11; - - -RowMatrixXd -planarize_quads( - RowMatrixXd V, - RowMatrixXi F, - int maxiter = 100, - double threshold = 0.005) -{ - RowMatrixXd Vplanar; - - igl::planarize_quad_mesh(V, F, maxiter, threshold, Vplanar); - - return Vplanar; -} - - -PYBIND11_MODULE(compas_libigl_planarize, m) { - m.def( - "planarize_quads", - &planarize_quads, - py::arg("V").noconvert(), - py::arg("F").noconvert(), - py::arg("maxiter") = 100, - py::arg("threshold") = 0.005); -} diff --git a/src/curvature.cpp b/src/curvature.cpp new file mode 100644 index 0000000..08104ea --- /dev/null +++ b/src/curvature.cpp @@ -0,0 +1,44 @@ +#include "curvature.hpp" + + +std::tuple +trimesh_principal_curvature( + Eigen::Ref V, + Eigen::Ref F, + int radius +) { + Eigen::MatrixXd PD1, PD2; + Eigen::VectorXd PV1, PV2; + igl::principal_curvature(V, F, PD1, PD2, PV1, PV2, radius); + return std::make_tuple(PD1, PD2, PV1, PV2); +} + +Eigen::VectorXd +trimesh_gaussian_curvature( + Eigen::Ref V, + Eigen::Ref F +) { + Eigen::VectorXd K; + igl::gaussian_curvature(V, F, K); + return K; +} + +NB_MODULE(_curvature, m) { + + m.doc() = "Mesh curvature computation functions using libigl"; + + m.def( + "trimesh_principal_curvature", + &trimesh_principal_curvature, + "Compute principal curvatures of a triangle mesh.", + "V"_a, "F"_a, "radius"_a=5 + ); + + m.def( + "trimesh_gaussian_curvature", + &trimesh_gaussian_curvature, + "Compute Gaussian curvature of a triangle mesh.", + "V"_a, "F"_a + ); + +} \ No newline at end of file diff --git a/src/curvature.hpp b/src/curvature.hpp new file mode 100644 index 0000000..157bdbc --- /dev/null +++ b/src/curvature.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "compas.hpp" +#include +#include +#include + +/** + * Compute the discrete gaussian curvature of a triangle mesh. + * + * @param V The vertex matrix of the triangle mesh (n x 3). + * @param F The face matrix of the triangle mesh (m x 3). + * @return A vector of gaussian curvature values per vertex. + */ +Eigen::VectorXd trimesh_gaussian_curvature( + Eigen::Ref V, + Eigen::Ref F +); + +/** + * Compute the principal curvatures and directions of a triangle mesh. + * + * @param V The vertex matrix of the triangle mesh (n x 3). + * @param F The face matrix of the triangle mesh (m x 3). + * @param radius The radius of the neighborhood for curvature computation. + * @return A tuple of (PD1, PD2, PV1, PV2) where: + * - PD1: principal direction 1 per vertex (n x 3) + * - PD2: principal direction 2 per vertex (n x 3) + * - PV1: principal curvature 1 per vertex (n x 1) + * - PV2: principal curvature 2 per vertex (n x 1) + */ +std::tuple +trimesh_principal_curvature( + Eigen::Ref V, + Eigen::Ref F, + int radius = 5 +); \ No newline at end of file diff --git a/src/geodistance.cpp b/src/geodistance.cpp new file mode 100644 index 0000000..1c75fc6 --- /dev/null +++ b/src/geodistance.cpp @@ -0,0 +1,130 @@ +#include "geodistance.hpp" + +// Main entry points +Eigen::VectorXd trimesh_geodistance( + Eigen::Ref V, + Eigen::Ref F, + int source, + const std::string& method +) { + Eigen::VectorXi sources(1); + sources << source; + + if (method == "exact") { + return trimesh_geodistance_exact(V, F, source); + } + else if (method == "heat") { + return trimesh_geodistance_heat(V, F, source); + } + throw std::runtime_error("Unknown method: " + method); +} + +Eigen::VectorXd trimesh_geodistance_multiple( + Eigen::Ref V, + Eigen::Ref F, + Eigen::Ref sources, + const std::string& method +) { + if (sources.size() == 0) { + throw std::runtime_error("No source vertices provided"); + } + + if (method == "exact") { + Eigen::VectorXd D; + Eigen::VectorXi VS = sources; // All source vertices + Eigen::VectorXi FS; // No face sources + Eigen::VectorXi VT; // All vertices as targets + VT.setLinSpaced(V.rows(), 0, V.rows() - 1); + Eigen::VectorXi FT; // No face targets + igl::exact_geodesic(V, F, VS, FS, VT, FT, D); + return D; + } + else if (method == "heat") { + igl::HeatGeodesicsData data; + double t = std::pow(igl::avg_edge_length(V, F), 2); + igl::heat_geodesics_precompute(V, F, t, data); + + Eigen::VectorXd D = Eigen::VectorXd::Zero(data.Grad.cols()); + // Set all source vertices to 1 + for (int i = 0; i < sources.size(); i++) { + D(sources(i)) = 1; + } + + igl::heat_geodesics_solve(data, sources, D); + return D; + } + throw std::runtime_error("Unknown method: " + method); +} + +// Implementation details +Eigen::VectorXd trimesh_geodistance_exact( + const compas::RowMatrixXd& V, + const compas::RowMatrixXi& F, + int vid) +{ + Eigen::VectorXd D; + Eigen::VectorXi VS, FS, VT, FT; + + VS.resize(1); + VS << vid; + + VT.setLinSpaced(V.rows(), 0, V.rows() - 1); + + igl::exact_geodesic(V, F, VS, FS, VT, FT, D); + + return D; +} + +Eigen::VectorXd trimesh_geodistance_heat( + const compas::RowMatrixXd& V, + const compas::RowMatrixXi& F, + int vid) +{ + Eigen::VectorXi gamma; + gamma.resize(1); + gamma << vid; + + igl::HeatGeodesicsData data; + double t = std::pow(igl::avg_edge_length(V, F), 2); + igl::heat_geodesics_precompute(V, F, t, data); + + Eigen::VectorXd D = Eigen::VectorXd::Zero(data.Grad.cols()); + D(vid) = 1; + + igl::heat_geodesics_solve(data, gamma, D); + + return D; +} + +NB_MODULE(_geodistance, m) { + + m.def( + "trimesh_geodistance", + &trimesh_geodistance, + "Compute geodesic distances from a source vertex to all other vertices.", + "V"_a, "F"_a, "source"_a, "method"_a="heat" + ); + + m.def( + "trimesh_geodistance_multiple", + &trimesh_geodistance_multiple, + "Compute geodesic distances from multiple source vertices to all other vertices.", + "V"_a, "F"_a, "sources"_a, "method"_a="heat" + ); + + m.def( + "trimesh_geodistance_exact", + &trimesh_geodistance_exact, + "Compute exact geodesic distances from a source vertex.", + "V"_a, + "F"_a, + "vid"_a); + + m.def( + "trimesh_geodistance_heat", + &trimesh_geodistance_heat, + "Compute heat method geodesic distances from a source vertex.", + "V"_a, + "F"_a, + "vid"_a); +} \ No newline at end of file diff --git a/src/geodistance.hpp b/src/geodistance.hpp new file mode 100644 index 0000000..e8278d4 --- /dev/null +++ b/src/geodistance.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "compas.hpp" +#include +#include +#include +#include + +/** + * Helper function to compute exact geodesic distances. + */ +Eigen::VectorXd trimesh_geodistance_exact( + const compas::RowMatrixXd& V, + const compas::RowMatrixXi& F, + int vid); + +/** + * Helper function to compute heat method geodesic distances. + */ +Eigen::VectorXd trimesh_geodistance_heat( + const compas::RowMatrixXd& V, + const compas::RowMatrixXi& F, + int vid); + +/** + * Compute geodesic distance from a source vertex to all other vertices. + * + * @param V The vertex matrix of the triangle mesh (n x 3). + * @param F The face matrix of the triangle mesh (m x 3). + * @param source Index of the source vertex. + * @param method Method to use: "exact" or "heat". + * @return Vector of geodesic distances from source to all vertices. + */ +Eigen::VectorXd trimesh_geodistance( + Eigen::Ref V, + Eigen::Ref F, + int source, + const std::string& method +); + +/** + * Compute geodesic distance from multiple source vertices. + * + * @param V The vertex matrix of the triangle mesh (n x 3). + * @param F The face matrix of the triangle mesh (m x 3). + * @param sources Vector of source vertex indices. + * @param method Method to use: "exact" or "heat". + * @return Vector of minimum geodesic distances from any source to all vertices. + */ +Eigen::VectorXd trimesh_geodistance_multiple( + Eigen::Ref V, + Eigen::Ref F, + Eigen::Ref sources, + const std::string& method +); \ No newline at end of file diff --git a/src/intersections.cpp b/src/intersections.cpp new file mode 100644 index 0000000..9404f8f --- /dev/null +++ b/src/intersections.cpp @@ -0,0 +1,68 @@ +#include "intersections.hpp" + +std::vector> +intersection_ray_mesh(const Eigen::Vector3d& point, const Eigen::Vector3d& direction, + Eigen::Ref V, + Eigen::Ref F) { + std::vector> hits; + std::vector> igl_hits; + + bool result = igl::ray_mesh_intersect(point, direction, V, F, igl_hits); + + if (result) { + for (const auto& hit : igl_hits) { + hits.emplace_back(hit.id, hit.u, hit.v, hit.t); + } + } + + return hits; +} + +std::vector>> +intersection_rays_mesh(Eigen::Ref points, + Eigen::Ref directions, + Eigen::Ref V, + Eigen::Ref F) { + std::vector>> hits_per_ray; + hits_per_ray.reserve(points.rows()); + + for (Eigen::Index i = 0; i < points.rows(); ++i) { + std::vector> igl_hits; + bool result = igl::ray_mesh_intersect(points.row(i), directions.row(i), V, F, igl_hits); + + std::vector> hits; + if (result) { + hits.reserve(igl_hits.size()); + for (const auto& hit : igl_hits) { + hits.emplace_back(hit.id, hit.u, hit.v, hit.t); + } + } + hits_per_ray.push_back(std::move(hits)); + } + + return hits_per_ray; +} + +NB_MODULE(_intersections, m) { + m.doc() = "Ray-mesh intersection functions using libigl"; + + m.def( + "intersection_ray_mesh", + &intersection_ray_mesh, + "Compute intersection between a single ray and a mesh", + "point"_a, + "direction"_a, + "V"_a, + "F"_a + ); + + m.def( + "intersection_rays_mesh", + &intersection_rays_mesh, + "Compute intersections between multiple rays and a mesh", + "points"_a, + "directions"_a, + "V"_a, + "F"_a + ); +} diff --git a/src/intersections.hpp b/src/intersections.hpp new file mode 100644 index 0000000..f51a693 --- /dev/null +++ b/src/intersections.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "compas.hpp" +#include +#include +#include + +/** + * Compute intersection between a single ray and a mesh. + * + * @param point Origin point of the ray + * @param direction Direction vector of the ray + * @param V #V x 3 matrix of vertex coordinates + * @param F #F x 3 matrix of triangle indices + * @return Vector of intersection hits, each containing (face_id, u, v, t) where: + * - face_id: index of intersected face + * - u, v: barycentric coordinates of hit point + * - t: ray parameter at intersection + */ +std::vector> +intersection_ray_mesh(const Eigen::Vector3d& point, const Eigen::Vector3d& direction, + Eigen::Ref V, + Eigen::Ref F); + +/** + * Compute intersections between multiple rays and a mesh. + * + * @param points #R x 3 matrix of ray origin points + * @param directions #R x 3 matrix of ray direction vectors + * @param V #V x 3 matrix of vertex coordinates + * @param F #F x 3 matrix of triangle indices + * @return Vector of intersection hits per ray, each hit containing (face_id, u, v, t) + */ +std::vector>> +intersection_rays_mesh(Eigen::Ref points, + Eigen::Ref directions, + Eigen::Ref V, + Eigen::Ref F); diff --git a/src/isolines.cpp b/src/isolines.cpp new file mode 100644 index 0000000..5743ceb --- /dev/null +++ b/src/isolines.cpp @@ -0,0 +1,31 @@ +#include "isolines.hpp" + +std::tuple trimesh_isolines( + Eigen::Ref V, + Eigen::Ref F, + Eigen::Ref isovalues, + Eigen::Ref vals) +{ + compas::RowMatrixXd iV; // iV by dim list of isoline vertex positions + compas::RowMatrixXi iE; // iE by 2 list of edge indices into iV + Eigen::VectorXi I; // ieE by 1 list of indices into vals indicating which value + + igl::isolines(V, F, isovalues, vals, iV, iE, I); + + return std::make_tuple(iV, iE, I); +} + +NB_MODULE(_isolines, m) { + + m.doc() = "Isoline computation functions using libigl"; + + m.def( + "trimesh_isolines", + &trimesh_isolines, + "Compute the isolines of a triangle mesh.", + "V"_a, + "F"_a, + "isovalues"_a, + "vals"_a + ); +} \ No newline at end of file diff --git a/src/isolines.hpp b/src/isolines.hpp new file mode 100644 index 0000000..65a2633 --- /dev/null +++ b/src/isolines.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "compas.hpp" +#include +#include + +namespace compas_libigl { + +/** + * Compute isolines on a triangle mesh. + * + * @param V The vertex matrix of the triangle mesh (n x 3). + * @param F The face matrix of the triangle mesh (m x 3). + * @param isovalues The scalar values per vertex (n x 1). + * @param vals The isovalues at which to compute isolines. + * @return Tuple of (vertices, edges, indices) defining the isolines. + */ +std::tuple +trimesh_isolines( + Eigen::Ref V, + Eigen::Ref F, + Eigen::Ref isovalues, + Eigen::Ref vals +); + +} // namespace compas_libigl diff --git a/src/massmatrix.cpp b/src/massmatrix.cpp new file mode 100644 index 0000000..ebb9c57 --- /dev/null +++ b/src/massmatrix.cpp @@ -0,0 +1,22 @@ +#include "massmatrix.hpp" + +Eigen::SparseMatrix +trimesh_massmatrix( + Eigen::Ref V, + Eigen::Ref F, + const std::string& type +) { + Eigen::SparseMatrix M; + igl::massmatrix(V, F, igl::MASSMATRIX_TYPE_BARYCENTRIC, M); + return M; +} + +NB_MODULE(_massmatrix, m) { + + m.def( + "trimesh_massmatrix", + &trimesh_massmatrix, + "Compute the mass matrix for a triangle mesh.", + "V"_a, "F"_a, "type"_a="barycentric" + ); +} \ No newline at end of file diff --git a/src/massmatrix.hpp b/src/massmatrix.hpp new file mode 100644 index 0000000..a5fee10 --- /dev/null +++ b/src/massmatrix.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "compas.hpp" +#include +#include +#include + +/** + * Compute the mass matrix of a triangle mesh. + * + * @param V The vertex positions of the mesh. + * @param F The face indices of the mesh. + * @param type The type of mass matrix to compute ('barycentric' or 'voronoi'). + * @return The mass matrix as a sparse matrix. + */ +Eigen::SparseMatrix trimesh_massmatrix( + Eigen::Ref V, + Eigen::Ref F, + const std::string& type = "voronoi" +); diff --git a/src/meshing.cpp b/src/meshing.cpp new file mode 100644 index 0000000..49b42d2 --- /dev/null +++ b/src/meshing.cpp @@ -0,0 +1,119 @@ +#include "meshing.hpp" + +std::tuple< + compas::RowMatrixXd, + compas::RowMatrixXi, + Eigen::VectorXi> +trimesh_remesh_along_isoline( + Eigen::Ref V1, + Eigen::Ref F1, + Eigen::Ref S1, + double s) +{ + // Check initial face components + Eigen::VectorXi C1; + igl::facet_components(F1, C1); + const int fc_count_before = C1.maxCoeff() + 1; // +1 because maxCoeff gives max index + + // Output variables for remeshing + compas::RowMatrixXd V2; + compas::RowMatrixXi F2; + Eigen::VectorXd S2; + Eigen::VectorXi J; + Eigen::SparseMatrix BC; + Eigen::VectorXi L; + + // Remesh along isoline + igl::remesh_along_isoline(V1, F1, S1, s, V2, F2, S2, J, BC, L); + + // Check face components after remeshing + Eigen::VectorXi C2; + igl::facet_components(F2, C2); + const int fc_count_after = C2.maxCoeff() + 1; + + // If component count changed, something went wrong + if (fc_count_before != fc_count_after) { + throw std::runtime_error("Face component count changed after remeshing"); + } + + // Return remeshed geometry and labels + return std::make_tuple(V2, F2, L); +} + +std::tuple< + compas::RowMatrixXd, + compas::RowMatrixXi, + Eigen::VectorXd, + Eigen::VectorXi> +trimesh_remesh_along_isolines( + Eigen::Ref V_initial, + Eigen::Ref F_initial, + Eigen::Ref S_initial, + Eigen::Ref values) +{ + // Pre-allocate all matrices with initial size + compas::RowMatrixXd V = V_initial; + compas::RowMatrixXi F = F_initial; + Eigen::VectorXd S = S_initial; + + // Initialize face groups + Eigen::VectorXi face_groups = Eigen::VectorXi::Zero(F.rows()); + + // Temporary variables - pre-allocated once + compas::RowMatrixXd V_temp; + compas::RowMatrixXi F_temp; + Eigen::VectorXd S_temp; + Eigen::VectorXi J, L; + Eigen::SparseMatrix BC; + + // Process each isoline value in sequence + for (int i = 0; i < values.size(); i++) { + // Remesh along current isoline + igl::remesh_along_isoline(V, F, S, values[i], V_temp, F_temp, S_temp, J, BC, L); + + // Update face groups - pre-allocate new array + Eigen::VectorXi new_face_groups = Eigen::VectorXi::Zero(F_temp.rows()); + + // Update face groups efficiently + for(Eigen::Index f = 0; f < F_temp.rows(); f++) { + if(J[f] >= 0) { + new_face_groups[f] = face_groups[J[f]]; + if(L[f] == 1) { + new_face_groups[f] = i + 1; // Use iteration number + 1 as group ID + } + } else { + new_face_groups[f] = i + 1; + } + } + + // Copy instead of move since we're using references + V = V_temp; + F = F_temp; + S = S_temp; + face_groups = new_face_groups; + } + + return std::make_tuple(V, F, S, face_groups); +} + +NB_MODULE(_meshing, m) { + m.doc() = "Mesh remeshing functions using libigl"; + + m.def( + "trimesh_remesh_along_isoline", + &trimesh_remesh_along_isoline, + "Remesh a triangle mesh along an isoline. Preserves the number of connected components.", + "V1"_a, + "F1"_a, + "S1"_a, + "s"_a); + + m.def( + "trimesh_remesh_along_isolines", + &trimesh_remesh_along_isolines, + "Remesh a triangle mesh along multiple isolines. Returns mesh data and face group IDs.", + "V1"_a, + "F1"_a, + "S1"_a, + "values"_a); +} diff --git a/src/meshing.hpp b/src/meshing.hpp new file mode 100644 index 0000000..d73aaa4 --- /dev/null +++ b/src/meshing.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "compas.hpp" +#include +#include +#include +#include + +/** + * Remesh a triangle mesh along an isoline. + * + * @param V1 #V x 3 matrix of vertex coordinates + * @param F1 #F x 3 matrix of triangle indices + * @param S1 #V x 1 vector of scalar values + * @param s Isovalue at which to cut + * @return Tuple of (vertices, faces, labels) where labels indicate which side of the cut each face is on + */ +std::tuple< + compas::RowMatrixXd, + compas::RowMatrixXi, + Eigen::VectorXi> +trimesh_remesh_along_isoline( + Eigen::Ref V1, + Eigen::Ref F1, + Eigen::Ref S1, + double s); + +/** + * Remesh a triangle mesh along multiple isolines. + * + * @param V_initial #V x 3 matrix of vertex coordinates + * @param F_initial #F x 3 matrix of triangle indices + * @param S_initial #V x 1 vector of scalar values + * @param values Vector of isovalues at which to cut + * @return Tuple of (vertices, faces, scalar values, face groups) where face groups indicate regions between cuts + */ +std::tuple< + compas::RowMatrixXd, + compas::RowMatrixXi, + Eigen::VectorXd, + Eigen::VectorXi> +trimesh_remesh_along_isolines( + Eigen::Ref V_initial, + Eigen::Ref F_initial, + Eigen::Ref S_initial, + Eigen::Ref values); diff --git a/src/nanobind.cpp b/src/nanobind.cpp new file mode 100644 index 0000000..27bda27 --- /dev/null +++ b/src/nanobind.cpp @@ -0,0 +1,14 @@ +#include +#include "compas.hpp" + +NB_MODULE(_nanobind, m) { + m.doc() = "COMPAS libigl nanobind bindings for geometry processing."; + + m.def("add", [](int a, int b) { return a + b; }, "a"_a, "b"_a, + "Add two numbers\n\n" + "Args:\n" + " a: First number\n" + " b: Second number\n\n" + "Returns:\n" + " Sum of a and b"); +} diff --git a/src/compas_libigl/parametrisation/parametrisation.cpp b/src/parametrisation.cpp similarity index 57% rename from src/compas_libigl/parametrisation/parametrisation.cpp rename to src/parametrisation.cpp index 2569d8b..ecba649 100644 --- a/src/compas_libigl/parametrisation/parametrisation.cpp +++ b/src/parametrisation.cpp @@ -1,20 +1,14 @@ -#include -#include +#include "parametrisation.hpp" #include -#include #include #include - -namespace py = pybind11; - -using RowMatrixXd = Eigen::Matrix; -using RowMatrixXi = Eigen::Matrix; - +#include +#include Eigen::MatrixXd -trimesh_harmonic_map( - RowMatrixXd V, - RowMatrixXi F) +harmonic( + Eigen::Ref V, + Eigen::Ref F) { Eigen::MatrixXd V_uv; @@ -32,11 +26,10 @@ trimesh_harmonic_map( return V_uv; } - Eigen::MatrixXd -trimesh_lscm( - RowMatrixXd V, - RowMatrixXi F) +lscm( + Eigen::Ref V, + Eigen::Ref F) { Eigen::MatrixXd V_uv; @@ -58,19 +51,18 @@ trimesh_lscm( return V_uv; } - -PYBIND11_MODULE(compas_libigl_parametrisation, m) { +NB_MODULE(_parametrisation, m) { m.def( - "trimesh_harmonic_map", - &trimesh_harmonic_map, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); + "harmonic", + &harmonic, + "Compute the harmonic parametrization of a triangle mesh.", + "V"_a, + "F"_a); m.def( - "trimesh_lscm", - &trimesh_lscm, - py::arg("V").noconvert(), - py::arg("F").noconvert() - ); -} + "lscm", + &lscm, + "Compute the least-squares conformal map of a triangle mesh.", + "V"_a, + "F"_a); +} \ No newline at end of file diff --git a/src/parametrisation.hpp b/src/parametrisation.hpp new file mode 100644 index 0000000..811868c --- /dev/null +++ b/src/parametrisation.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "compas.hpp" +#include + +/** + * Compute the harmonic parametrization of a triangle mesh. + * + * @param V #V x 3 matrix of vertex coordinates + * @param F #F x 3 matrix of triangle indices + * @return UV coordinates for each vertex + */ +Eigen::MatrixXd +harmonic( + Eigen::Ref V, + Eigen::Ref F); + +/** + * Compute the least-squares conformal map of a triangle mesh. + * + * @param V #V x 3 matrix of vertex coordinates + * @param F #F x 3 matrix of triangle indices + * @return UV coordinates for each vertex + */ +Eigen::MatrixXd +lscm( + Eigen::Ref V, + Eigen::Ref F); diff --git a/src/planarize.cpp b/src/planarize.cpp new file mode 100644 index 0000000..e57b1eb --- /dev/null +++ b/src/planarize.cpp @@ -0,0 +1,27 @@ +#include "planarize.hpp" +#include + +compas::RowMatrixXd +planarize_quads( + compas::RowMatrixXd V, + compas::RowMatrixXi F, + int maxiter, + double threshold) +{ + compas::RowMatrixXd Vplanar; + + igl::planarize_quad_mesh(V, F, maxiter, threshold, Vplanar); + + return Vplanar; +} + +NB_MODULE(_planarize, m) { + m.def( + "planarize_quads", + &planarize_quads, + "Planarize a quad mesh.", + "V"_a, + "F"_a, + "maxiter"_a, + "threshold"_a); +} \ No newline at end of file diff --git a/src/planarize.hpp b/src/planarize.hpp new file mode 100644 index 0000000..d7d0acc --- /dev/null +++ b/src/planarize.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "compas.hpp" +#include + +/** + * Planarize a quad mesh. + * + * @param V #V x 3 matrix of vertex coordinates + * @param F #F x 4 matrix of quad indices + * @param maxiter Maximum number of iterations + * @param threshold Convergence threshold + * @return Matrix of planarized vertex coordinates + */ +compas::RowMatrixXd +planarize_quads( + compas::RowMatrixXd V, + compas::RowMatrixXi F, + int maxiter = 100, + double threshold = 0.005); \ No newline at end of file diff --git a/src/types_std.cpp b/src/types_std.cpp new file mode 100644 index 0000000..36757ab --- /dev/null +++ b/src/types_std.cpp @@ -0,0 +1,11 @@ +#include "types_std.hpp" + +NB_MODULE(_types_std, m) { + + nb::bind_vector>(m, "VectorDouble"); + nb::bind_vector>(m, "VectorInt"); + nb::bind_vector>>(m, "VectorVectorInt"); + nb::bind_vector>(m, "VectorRowMatrixXd"); + nb::bind_vector>>(m, "VectorTupleIntFloatFloatFloat"); + nb::bind_vector>>>(m, "VectorVectorTupleIntFloatFloatFloat"); +} \ No newline at end of file diff --git a/src/types_std.hpp b/src/types_std.hpp new file mode 100644 index 0000000..8b51e5f --- /dev/null +++ b/src/types_std.hpp @@ -0,0 +1,3 @@ +#pragma once + +#include "compas.hpp" \ No newline at end of file diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..8062238 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,9 @@ +import compas_libigl as m + + +def test_add(): + assert m.add(1, 2) == 3 + + +print(m.add(1, 2)) +print(m.__doc__) diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py new file mode 100644 index 0000000..a1b84a8 --- /dev/null +++ b/tests/test_boundaries.py @@ -0,0 +1,11 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh + + +def test_trimesh_boundaries(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + boundaries = compas_libigl.trimesh_boundaries(M) + assert len(boundaries) == 1 diff --git a/tests/test_curvature.py b/tests/test_curvature.py new file mode 100644 index 0000000..6d4a549 --- /dev/null +++ b/tests/test_curvature.py @@ -0,0 +1,19 @@ +import compas +import compas_libigl as libigl +from compas.datastructures import Mesh + + +def test_trimesh_gaussian_curvature(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + K = libigl.trimesh_gaussian_curvature(M) + assert len(K) == mesh.number_of_vertices() + + +def test_trimesh_principal_curvature(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + PD1, PD2, PV1, PV2 = libigl.trimesh_principal_curvature(M) + assert len(PV1) == mesh.number_of_vertices() diff --git a/tests/test_default.py b/tests/test_default.py deleted file mode 100644 index bb2ae50..0000000 --- a/tests/test_default.py +++ /dev/null @@ -1,6 +0,0 @@ -import compas_libigl - - -def test_trivial(): - print(compas_libigl.__version__) - assert 1 == 1 diff --git a/tests/test_geodistance.py b/tests/test_geodistance.py new file mode 100644 index 0000000..eb2e39f --- /dev/null +++ b/tests/test_geodistance.py @@ -0,0 +1,25 @@ +import compas +import compas_libigl as libigl +from compas.datastructures import Mesh + + +def test_trimesh_geodistance(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + source = 0 + distances = libigl.trimesh_geodistance(M, source, method="exact") + assert len(distances) == mesh.number_of_vertices() + distances = libigl.trimesh_geodistance(M, source, method="heat") + assert len(distances) == mesh.number_of_vertices() + + +def test_trimesh_geodistance_multiple(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + sources = [0, 1] + distances = libigl.trimesh_geodistance_multiple(M, sources, method="exact") + assert len(distances) == mesh.number_of_vertices() + distances = libigl.trimesh_geodistance_multiple(M, sources, method="heat") + assert len(distances) == mesh.number_of_vertices() diff --git a/tests/test_intersections.py b/tests/test_intersections.py new file mode 100644 index 0000000..edd5429 --- /dev/null +++ b/tests/test_intersections.py @@ -0,0 +1,27 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh +from compas.geometry import add_vectors, scale_vector + + +def test_intersection_ray_mesh(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + centroid = mesh.centroid() + ray = [centroid[0], centroid[1], 0], [0, 0, 1.0] + hits = compas_libigl.intersection_ray_mesh(ray, M) + assert len(hits) == 1 + # Test computing intersection point + point = add_vectors(ray[0], scale_vector(ray[1], hits[0][3])) + assert len(point) == 3 + + +def test_intersection_rays_mesh(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + centroid = mesh.centroid() + rays = [([centroid[0], centroid[1], 0], [0, 0, 1.0]), ([centroid[0], centroid[1], 1], [0, 0, -1.0])] + hits = compas_libigl.intersection_rays_mesh(rays, M) + assert len(hits) == 2 diff --git a/tests/test_isolines.py b/tests/test_isolines.py new file mode 100644 index 0000000..792a6f1 --- /dev/null +++ b/tests/test_isolines.py @@ -0,0 +1,30 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh +import numpy as np + + +def test_trimesh_isolines(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + scalars = np.array(mesh.vertices_attribute("z"), dtype=np.float64) + # Calculate mean z value for isovalue + mean_z = np.mean(scalars) + isovalues = np.array([mean_z], dtype=np.float64) + vertices, edges, index = compas_libigl.trimesh_isolines(M, scalars, isovalues) + assert len(vertices) > 0 + assert len(edges) > 0 + + +def test_groupsort_isolines(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + scalars = np.array(mesh.vertices_attribute("z"), dtype=np.float64) + # Calculate mean z value for isovalue + mean_z = np.mean(scalars) + isovalues = np.array([mean_z], dtype=np.float64) + vertices, edges, index = compas_libigl.trimesh_isolines(M, scalars, isovalues) + polyline_groups = compas_libigl.groupsort_isolines(vertices, edges, index) + assert len(polyline_groups) > 0 diff --git a/tests/test_massmatrix.py b/tests/test_massmatrix.py new file mode 100644 index 0000000..3344572 --- /dev/null +++ b/tests/test_massmatrix.py @@ -0,0 +1,11 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh + + +def test_trimesh_massmatrix(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + mass = compas_libigl.trimesh_massmatrix(M) + assert mass.shape[0] == mesh.number_of_vertices() # Use shape[0] for sparse matrix length diff --git a/tests/test_meshing.py b/tests/test_meshing.py new file mode 100644 index 0000000..74aff86 --- /dev/null +++ b/tests/test_meshing.py @@ -0,0 +1,31 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh + + +def test_trimesh_remesh_along_isoline(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + scalars = mesh.vertices_attribute("z") + mean_z = sum(scalars) / len(scalars) + V2, F2, L = compas_libigl.trimesh_remesh_along_isoline(M, scalars, mean_z) + # Labels array L has one entry per face in F2 + assert len(L) == len(F2) + assert len(V2) > 0 + assert len(F2) > 0 + + +def test_trimesh_remesh_along_isolines(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + scalars = mesh.vertices_attribute("z") + z_min, z_max = min(scalars), max(scalars) + # Create 5 evenly spaced isolines + isovalues = [z_min + i * (z_max - z_min) / 5 for i in range(1, 5)] + V2, F2, S2, G2 = compas_libigl.trimesh_remesh_along_isolines(M, scalars, isovalues) + assert len(V2) > 0 + assert len(F2) > 0 + assert len(S2) == len(V2) + assert len(G2) == len(F2) diff --git a/tests/test_parametrisation.py b/tests/test_parametrisation.py new file mode 100644 index 0000000..2d09990 --- /dev/null +++ b/tests/test_parametrisation.py @@ -0,0 +1,21 @@ +import compas +import compas_libigl as igl +from compas.datastructures import Mesh + + +def test_trimesh_harmonic(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + uv = igl.trimesh_harmonic(M) + assert len(uv) == mesh.number_of_vertices() + assert len(uv[0]) == 2 # Each UV coordinate should be 2D + + +def test_trimesh_lscm(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + mesh.quads_to_triangles() + M = mesh.to_vertices_and_faces() + uv = igl.trimesh_lscm(M) + assert len(uv) == mesh.number_of_vertices() + assert len(uv[0]) == 2 # Each UV coordinate should be 2D diff --git a/tests/test_planarize.py b/tests/test_planarize.py new file mode 100644 index 0000000..c390d89 --- /dev/null +++ b/tests/test_planarize.py @@ -0,0 +1,10 @@ +import compas +import compas_libigl +from compas.datastructures import Mesh + + +def test_quadmesh_planarize(): + mesh = Mesh.from_off(compas.get("tubemesh.off")) + M = mesh.to_vertices_and_faces() + new_vertices = compas_libigl.quadmesh_planarize(M, kmax=10, maxdev=0.01) + assert len(new_vertices) == mesh.number_of_vertices()