├── .github └── workflows │ ├── cibuildwheel_config.toml │ ├── publish.yml │ ├── test_build.yml │ ├── test_linux.yml │ ├── test_macos.yml │ └── test_windows.yml ├── .gitignore ├── .gitmodules ├── CMakeLists.txt ├── LICENSE ├── MANIFEST.in ├── README.md ├── media ├── elephant_geodesic.jpg └── point_heat_solvers.jpg ├── pyproject.toml ├── setup.py ├── src ├── cpp │ ├── .clang-format │ ├── core.cpp │ ├── core.h │ ├── io.cpp │ ├── mesh.cpp │ └── point_cloud.cpp └── potpourri3d │ ├── __init__.py │ ├── core.py │ ├── io.py │ ├── mesh.py │ └── point_cloud.py └── test ├── bunny_small.ply ├── potpourri3d_test.py └── sample.py /.github/workflows/cibuildwheel_config.toml: -------------------------------------------------------------------------------- 1 | [tool.cibuildwheel] 2 | skip = "cp36-*" # scikit-build-core requires >=3.7 3 | build-verbosity = 3 4 | 5 | [tool.cibuildwheel.linux] 6 | before-all = [ 7 | "yum remove -y cmake", 8 | ] 9 | 10 | # musllinux builds on an Alpinx Linux image, no need to mess with cmake there 11 | [[tool.cibuildwheel.overrides]] 12 | select = "*-musllinux*" 13 | before-all = "" -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | # NOTE: build logic is duplicated here and in test_build.yml 4 | 5 | # Run on the main branch for commits only 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build_wheels: 13 | 14 | # only run if the most recent commit contains '[ci publish]' 15 | if: "contains(github.event.head_commit.message, '[ci publish]')" 16 | 17 | strategy: 18 | matrix: 19 | # macos-13 is an intel runner, macos-14 is apple silicon 20 | os: [ubuntu-latest, windows-latest, macos-13, macos-14] 21 | 22 | name: Build wheels ${{ matrix.os }} 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | submodules: 'recursive' 29 | 30 | - name: Package source distribution 31 | if: runner.os == 'Linux' 32 | run: | 33 | python -m pip install build 34 | python -m build --sdist 35 | 36 | - name: Run cibuildwheel 37 | uses: pypa/cibuildwheel@v2.21 38 | with: 39 | config-file: ".github/workflows/cibuildwheel_config.toml" 40 | 41 | - name: Copy source distribution into wheelhouse 42 | if: runner.os == 'Linux' 43 | run: mv dist/*.tar.gz wheelhouse/ 44 | 45 | # Upload binaries to the github artifact store 46 | - name: Upload wheels 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: cibw-wheels-${{ matrix.os }} 50 | path: | 51 | ./wheelhouse/*.whl 52 | ./wheelhouse/*.tar.gz 53 | overwrite: true 54 | 55 | # Push the resulting binaries to pypi on a tag starting with 'v' 56 | upload_pypi: 57 | name: Upload release to PyPI 58 | 59 | # only run if the most recent commit contains '[ci publish]' 60 | if: "contains(github.event.head_commit.message, '[ci publish]')" 61 | 62 | needs: [build_wheels] 63 | runs-on: ubuntu-latest 64 | environment: 65 | name: pypi 66 | url: https://pypi.org/p/potpourri3d 67 | permissions: # we authenticate via PyPI's 'trusted publisher' workflow, this permission is required 68 | id-token: write 69 | steps: 70 | - name: Download built wheels artifact # downloads from the jobs storage from the previous step 71 | uses: actions/download-artifact@v4.1.7 72 | with: 73 | # omitting the `name: ` field downloads all artifacts from this workflow 74 | path: dist 75 | 76 | - name: List downloaded files from artifact 77 | run: ls dist | cat # piping through cat prints one per line 78 | 79 | # dist directory has subdirs from the different jobs, merge them into one directory and delete 80 | # the empty leftover dirs 81 | - name: Flatten directory 82 | run: find dist -mindepth 2 -type f -exec mv -t dist {} + && find dist -type d -empty -delete 83 | 84 | - name: List downloaded files from artifact after flatten 85 | run: ls dist | cat # piping through cat prints one per line 86 | 87 | - name: Publish package to PyPI 88 | uses: pypa/gh-action-pypi-publish@release/v1 89 | # with: 90 | # To test: repository_url: https://test.pypi.org/legacy/ 91 | 92 | -------------------------------------------------------------------------------- /.github/workflows/test_build.yml: -------------------------------------------------------------------------------- 1 | name: Test Build 2 | 3 | # NOTE: build logic is duplicated here and in publish.yml 4 | 5 | # Run on the master branch commit push and PRs to master (note conditional below) 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | build_wheels: 16 | 17 | # Only run if the commit message contains '[ci build]' 18 | if: "contains(toJSON(github.event.commits.*.message), '[ci build]') || contains(toJSON(github.event.pull_request.title), '[ci build]')" 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | # macos-13 is an intel runner, macos-14 is apple silicon 24 | os: [ubuntu-latest, windows-latest, macos-13, macos-14] 25 | 26 | name: Build wheels ${{ matrix.os }} 27 | runs-on: ${{ matrix.os }} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | submodules: 'recursive' 33 | 34 | - name: Package source distribution 35 | if: runner.os == 'Linux' 36 | run: | 37 | python -m pip install build 38 | python -m build --sdist 39 | 40 | - name: Run cibuildwheel 41 | uses: pypa/cibuildwheel@v2.21 42 | with: 43 | config-file: ".github/workflows/cibuildwheel_config.toml" 44 | 45 | # Upload binaries to the github artifact store 46 | - name: Upload wheels 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: cibw-wheels-${{ matrix.os }} 50 | path: | 51 | ./wheelhouse/*.whl 52 | ./wheelhouse/*.tar.gz 53 | overwrite: true -------------------------------------------------------------------------------- /.github/workflows/test_linux.yml: -------------------------------------------------------------------------------- 1 | name: Test Linux 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | if: "! contains(toJSON(github.event.commits.*.message), '[ci skip]')" 13 | steps: 14 | - uses: actions/checkout@v1 15 | with: 16 | submodules: 'recursive' 17 | 18 | - uses: actions/setup-python@v5 19 | name: Install Python 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: install python packages 24 | run: python3 -m pip install numpy scipy 25 | 26 | - name: configure 27 | run: mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug -DPYTHON_EXECUTABLE=$(python3 -c "import sys; print(sys.executable)") .. 28 | 29 | - name: build 30 | run: cd build && make 31 | 32 | - name: run test 33 | run: python3 test/potpourri3d_test.py 34 | -------------------------------------------------------------------------------- /.github/workflows/test_macos.yml: -------------------------------------------------------------------------------- 1 | name: Test macOS 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | if: "! contains(toJSON(github.event.commits.*.message), '[ci skip]')" 13 | steps: 14 | - uses: actions/checkout@v1 15 | with: 16 | submodules: 'recursive' 17 | 18 | - uses: actions/setup-python@v5 19 | name: Install Python 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: install python packages 24 | run: python3 -m pip install numpy scipy 25 | 26 | - name: configure 27 | run: mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. 28 | 29 | - name: build 30 | run: cd build && make 31 | 32 | - name: run test 33 | run: python3 test/potpourri3d_test.py 34 | -------------------------------------------------------------------------------- /.github/workflows/test_windows.yml: -------------------------------------------------------------------------------- 1 | name: Test Windows 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: windows-latest 12 | if: "! contains(toJSON(github.event.commits.*.message), '[ci skip]')" 13 | steps: 14 | - uses: actions/checkout@v1 15 | with: 16 | submodules: 'recursive' 17 | 18 | - uses: actions/setup-python@v5 19 | name: Install Python 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: install python packages 24 | run: python -m pip install numpy scipy 25 | 26 | - name: configure 27 | run: mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. 28 | 29 | - name: build 30 | run: cd build && cmake --build "." 31 | 32 | - name: run test 33 | run: python test/potpourri3d_test.py 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build directories 2 | build/ 3 | build_debug/ 4 | dist/ 5 | *.pyc 6 | __pycache__/ 7 | 8 | *.egg-info 9 | 10 | # Test things 11 | test/*.obj 12 | test/*.ply 13 | 14 | # Editor and OS things 15 | imgui.ini 16 | .polyscope.ini 17 | .DS_Store 18 | .vscode 19 | *.swp 20 | tags 21 | *.blend1 22 | 23 | # Prerequisites 24 | *.d 25 | 26 | # Compiled Object files 27 | *.slo 28 | *.lo 29 | *.o 30 | *.obj 31 | 32 | # Precompiled Headers 33 | *.gch 34 | *.pch 35 | 36 | # Compiled Dynamic libraries 37 | *.so 38 | *.dylib 39 | *.dll 40 | 41 | # Fortran module files 42 | *.mod 43 | *.smod 44 | 45 | # Compiled Static libraries 46 | *.lai 47 | *.la 48 | *.a 49 | *.lib 50 | 51 | # Executables 52 | *.exe 53 | *.out 54 | *.app 55 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/eigen"] 2 | path = deps/eigen 3 | url = https://gitlab.com/libeigen/eigen.git 4 | [submodule "deps/geometry-central"] 5 | path = deps/geometry-central 6 | url = https://github.com/nmwsharp/geometry-central.git 7 | [submodule "deps/pybind11"] 8 | path = deps/pybind11 9 | url = https://github.com/pybind/pybind11.git 10 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.1.0) 2 | project(potpourri3d_bindings) 3 | 4 | # Recurse in to pybind 5 | set(PYBIND11_NEWPYTHON ON) 6 | add_subdirectory(deps/pybind11) 7 | 8 | # set location of eigen for geometry-central 9 | set(GC_EIGEN_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/deps/eigen" CACHE PATH "my path") 10 | 11 | # geometry-central 12 | set(CMAKE_POSITION_INDEPENDENT_CODE ON) 13 | add_subdirectory(deps/geometry-central) 14 | 15 | pybind11_add_module(potpourri3d_bindings 16 | src/cpp/core.cpp 17 | src/cpp/io.cpp 18 | src/cpp/mesh.cpp 19 | src/cpp/point_cloud.cpp 20 | ) 21 | 22 | include_directories(potpourri3d_bindings ${CMAKE_CURRENT_SOURCE_DIR}/src/cpp) 23 | 24 | target_link_libraries(potpourri3d_bindings PRIVATE geometry-central) 25 | 26 | install(TARGETS potpourri3d_bindings LIBRARY DESTINATION .) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nicholas Sharp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | include CMakeLists.txt 3 | recursive-include deps/geometry-central *.cpp *.h *.ipp *.hpp *.cmake CMakeLists.txt 4 | recursive-include deps/eigen/Eigen * 5 | include deps/eigen/COPYING* 6 | include deps/eigen/README.md 7 | recursive-include deps/pybind11 *.h CMakeLists.txt *.cmake 8 | recursive-include src *.cpp *.h 9 | recursive-include src/potpourri3d *.py 10 | exclude dist 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # potpourri3d 2 | 3 | A Python library of various algorithms and utilities for 3D triangle meshes and point clouds. Managed by [Nicholas Sharp](https://nmwsharp.com), with new tools added lazily as needed. Currently, mainly bindings to C++ tools from [geometry-central](http://geometry-central.net/). 4 | 5 | `pip install potpourri3d` 6 | 7 | The blend includes: 8 | - Mesh and point cloud reading/writing to a few file formats 9 | - Use **heat methods** to compute distance, parallel transport, logarithmic maps, and more 10 | - Computing geodesic polylines along surface via edge flips 11 | - More! 12 | 13 | ## Installation 14 | 15 | Potpourri3d is on the pypi package index with precompiled binaries for most configuations. Get it like: 16 | 17 | `pip install potpourri3d` 18 | 19 | If none of the precompiled binaries match your system, `pip` will attempt to compile the library from scratch. This requires `cmake` and a workng C++ compiler toolchain. 20 | 21 | **Note**: Some bound functions invoke sparse linear solvers internally. The precompiled binaries use Eigen's solvers; using Suitesparse's solvers instead may significantly improve performance & robustness. To get them, locally compile the package on a machine with Suitesparse installed using the command below ([relevant docs](http://geometry-central.net/build/dependencies/#suitesparse)). 22 | 23 | ``` 24 | python -m pip install potpourri3d --no-binary potpourri3d 25 | ``` 26 | 27 | ## Documentation 28 | 29 | - [Input / Output](#input--output) 30 | - [Mesh basic utilities](#mesh-basic-utilities) 31 | - [Mesh Distance](#mesh-distance) 32 | - [Mesh Vector Heat](#mesh-vector-heat) 33 | - [Mesh Geodesic Paths](#mesh-geodesic-paths) 34 | - [Mesh Geodesic Tracing](#mesh-geodesic-tracing) 35 | - [Point Cloud Distance & Vector Heat](#point-cloud-distance--vector-heat) 36 | - [Other Point Cloud Routines](#other-point-cloud-routines) 37 | 38 | ### Input / Output 39 | 40 | Read/write meshes and point clouds from some common formats. 41 | 42 | - `read_mesh(filename)` Reads a mesh from file. Returns numpy matrices `V, F`, a Nx3 real numpy array of vertices and a Mx3 integer numpy array of 0-based face indices (or Mx4 for a quad mesh, etc). 43 | - `filename` the path to read the file from. Currently supports the same file types as [geometry-central](http://geometry-central.net/surface/utilities/io/#supported-file-types). The file type is inferred automatically from the path extension. 44 | 45 | - `write_mesh(V, F, filename, UV_coords=None, UV_type=None)` Write a mesh to file, optionally with UV coords. 46 | - `V` a Nx3 real numpy array of vertices 47 | - `F` a Mx3 integer numpy array of faces, with 0-based vertex indices (or Mx4 for a quad mesh, etc). 48 | - `filename` the path to write the file to. Currently supports the same file types as [geometry-central](http://geometry-central.net/surface/utilities/io/#supported-file-types). The file type is inferred automatically from the path extension. 49 | - `UV_coords` (optional) a Ux2 numpy array of UV coords, interpreted based on UV_type. *Warning:* this function does not currently preserve shared UV indices when writing, each written coordinate is independent 50 | - `UV_type` (optional) string, one of `'per-vertex'`, `'per-face'`, or `'per-corner'`. The size of `U` should be `N`, `M`, or `M*3/4`, respectively 51 | 52 | - `read_point_cloud(filename)` Reads a point cloud from file. Returns numpy matrix `V`, a Nx3 real numpy array of vertices. Really, this just reads a mesh file and ignores the face entries. 53 | - `filename` the path to read the file from. Currently supports the same file types as [geometry-central](http://geometry-central.net/surface/utilities/io/#supported-file-types)'s mesh reader. The file type is inferred automatically from the path extension. 54 | 55 | - `write_point_cloud(V, filename)` Write a mesh to file. Really, this just writes a mesh file with no face entries. 56 | - `V` a Nx3 real numpy array of vertices 57 | - `filename` the path to write the file to. Currently supports the same file types as [geometry-central](http://geometry-central.net/surface/utilities/io/#supported-file-types)'s mesh writer. The file type is inferred automatically from the path extension. 58 | 59 | ### Mesh basic utilities 60 | 61 | - `face_areas(V, F)` computes a length-F real numpy array of face areas for a triangular mesh 62 | - `vertex_areas(V, F)` computes a length-V real numpy array of vertex areas for a triangular mesh (equal to 1/3 the sum of the incident face areas) 63 | - `cotan_laplacian(V, F, denom_eps=0.)` computes the cotan-Laplace matrix as a VxV real sparse csr scipy matrix. Optionally, set `denom_eps` to a small value like `1e-6` to get some additional stability in the presence of degenerate faces. 64 | 65 | ### Mesh Distance 66 | 67 | Use the [heat method for geodesic distance](https://www.cs.cmu.edu/~kmcrane/Projects/HeatMethod/) to compute geodesic distance on surfaces. Repeated solves are fast after initial setup. Uses [intrinsic triangulations](http://www.cs.cmu.edu/~kmcrane/Projects/NonmanifoldLaplace/NonmanifoldLaplace.pdf) internally for increased robustness. 68 | 69 | ```python 70 | import potpourri3d as pp3d 71 | 72 | # = Stateful solves (much faster if computing distance many times) 73 | solver = pp3d.MeshHeatMethodDistanceSolver(V,F) 74 | dist = solver.compute_distance(7) 75 | dist = solver.compute_distance_multisource([1,2,3]) 76 | 77 | # = One-off versions 78 | dist = pp3d.compute_distance(V,F,7) 79 | dist = pp3d.compute_distance_multisource(V,F,[1,3,4]) 80 | ``` 81 | 82 | The heat method works by solving a sequence of linear PDEs on the surface of your shape. On extremely coarse meshes, it may yield inaccurate results, if you observe this, consider using a finer mesh to improve accuracy. (TODO: do this internally with intrinsic Delaunay refinement.) 83 | 84 | - `MeshHeatMethodDistanceSolver(V, F, t_coef=1., use_robust=True)` construct an instance of the solver class. 85 | - `V` a Nx3 real numpy array of vertices 86 | - `F` a Mx3 integer numpy array of faces, with 0-based vertex indices (triangle meshes only, but need not be manifold). 87 | - `t_coef` set the time used for short-time heat flow. Generally don't change this. If necessary, larger values may make the solution more stable at the cost of smoothing it out. 88 | - `use_robust` use intrinsic triangulations for increased robustness. Generaly leave this enabled. 89 | - `MeshHeatMethodDistanceSolver.compute_distance(v_ind)` compute distance from a single vertex, given by zero-based index. Returns an array of distances. 90 | - `MeshHeatMethodDistanceSolver.compute_distance_multisource(v_ind_list)` compute distance from the nearest of a collection of vertices, given by a list of zero-based indices. Returns an array of distances. 91 | - `compute_distance(V, F, v_ind)` Similar to above, but one-off instead of stateful. Returns an array of distances. 92 | - `compute_distance_multisource(V, F, v_ind_list)` Similar to above, but one-off instead of stateful. Returns an array of distances. 93 | 94 | ### Mesh Vector Heat 95 | 96 | Use the [vector heat method](https://nmwsharp.com/research/vector-heat-method/) to compute various interpolation & vector-based quantities on meshes. Repeated solves are fast after initial setup. 97 | 98 | ```python 99 | import potpourri3d as pp3d 100 | 101 | # = Stateful solves 102 | V, F = # a Nx3 numpy array of points and Mx3 array of triangle face indices 103 | solver = pp3d.MeshVectorHeatSolver(V,F) 104 | 105 | # Extend the value `0.` from vertex 12 and `1.` from vertex 17. Any vertex 106 | # geodesically closer to 12. will take the value 0., and vice versa 107 | # (plus some slight smoothing) 108 | ext = solver.extend_scalar([12, 17], [0.,1.]) 109 | 110 | # Get the tangent frames which are used by the solver to define tangent data 111 | # at each vertex 112 | basisX, basisY, basisN = solver.get_tangent_frames() 113 | 114 | # Parallel transport a vector along the surface 115 | # (and map it to a vector in 3D) 116 | sourceV = 22 117 | ext = solver.transport_tangent_vector(sourceV, [6., 6.]) 118 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 119 | 120 | # Compute the logarithmic map 121 | logmap = solver.compute_log_map(sourceV) 122 | ``` 123 | 124 | - `MeshVectorHeatSolver(V, F, t_coef=1.)` construct an instance of the solver class. 125 | - `V` a Nx3 real numpy array of vertices 126 | - `F` a Mx3 integer numpy array of faces, with 0-based vertex indices (triangle meshes only, should be manifold). 127 | - `t_coef` set the time used for short-time heat flow. Generally don't change this. If necessary, larger values may make the solution more stable at the cost of smoothing it out. 128 | - `MeshVectorHeatSolver.extend_scalar(v_inds, values)` nearest-geodesic-neighbor interpolate values defined at vertices. Vertices will take the value from the closest source vertex (plus some slight smoothing) 129 | - `v_inds` a list of source vertices 130 | - `values` a list of scalar values, one for each source vertex 131 | - `MeshVectorHeatSolver.get_tangent_frames()` get the coordinate frames used to define tangent data at each vertex. Returned as a tuple of basis-X, basis-Y, and normal axes, each as an Nx3 array. May be necessary for change-of-basis into or out of tangent vector convention. 132 | - `MeshVectorHeatSolver.get_connection_laplacian()` get the _connection Laplacian_ used internally in the vector heat method, as a VxV sparse matrix. 133 | - `MeshVectorHeatSolver.transport_tangent_vector(v_ind, vector)` parallel transports a single vector across a surface 134 | - `v_ind` index of the source vertex 135 | - `vector` a 2D tangent vector to transport 136 | - `MeshVectorHeatSolver.transport_tangent_vectors(v_inds, vectors)` parallel transports a collection of vectors across a surface, such that each vertex takes the vector from its nearest-geodesic-neighbor. 137 | - `v_inds` a list of source vertices 138 | - `vectors` a list of 2D tangent vectors, one for each source vertex 139 | - `MeshVectorHeatSolver.compute_log_map(v_ind)` compute the logarithmic map centered at the given source vertex 140 | - `v_ind` index of the source vertex 141 | 142 | 143 | ### Mesh Geodesic Paths 144 | 145 | Use [edge flips to compute geodesic paths](https://nmwsharp.com/research/flip-geodesics/) on surfaces. These methods take an initial path, loop, or start & end points along the surface, and straighten the path out to be geodesic. 146 | 147 | This approach is mainly useful when you want the path itself, rather than the distance. These routines use an iterative strategy which is quite fast, but note that it is not guaranteed to generate a globally-shortest geodesic (they sometimes find some other very short geodesic instead if straightening falls into different local minimum). 148 | 149 | 150 | 151 | ```python 152 | import potpourri3d as pp3d 153 | 154 | V, F = # your mesh 155 | path_solver = pp3d.EdgeFlipGeodesicSolver(V,F) # shares precomputation for repeated solves 156 | path_pts = path_solver.find_geodesic_path(v_start=14, v_end=22) 157 | # path_pts is a Vx3 numpy array of points forming the path 158 | ``` 159 | 160 | - `EdgeFlipGeodesicSolver(V, F)` construct an instance of the solver class. 161 | - `V` a Nx3 real numpy array of vertices 162 | - `F` a Mx3 integer numpy array of faces, with 0-based vertex indices (must form a manifold, oriented triangle mesh). 163 | - `EdgeFlipGeodesicSolver.find_geodesic_path(v_start, v_end, max_iterations=None, max_relative_length_decrease=None)` compute a geodesic from `v_start` to `v_end`. Output is an `Nx3` numpy array of positions which define the path as a polyline along the surface. 164 | - `EdgeFlipGeodesicSolver.find_geodesic_path_poly(v_list, max_iterations=None, max_relative_length_decrease=None)` like `find_geodesic_path()`, but takes as input a list of vertices `[v_start, v_a, v_b, ..., v_end]`, which is shorted to find a path from `v_start` to `v_end`. Useful for finding geodesics which are not shortest paths. The input vertices do not need to be connected; the routine internally constructs a piecwise-Dijkstra path between them. However, that path must not cross itself. 165 | - `EdgeFlipGeodesicSolver.find_geodesic_loop(v_list, max_iterations=None, max_relative_length_decrease=None)` like `find_geodesic_path_poly()`, but connects the first to last point to find a closed geodesic loop. 166 | 167 | In the functions above, the optional argument `max_iterations` is an integer, giving the the maximum number of shortening iterations to perform (default: no limit). The optional argument `max_relative_length_decrease` is a float limiting the maximum decrease in length for the path, e.g. `0.5` would mean the resulting path is at least `0.5 * L` length, where `L` is the initial length. 168 | 169 | ### Mesh Geodesic Tracing 170 | 171 | Given an initial point and direction/length, these routines trace out a geodesic path along the surface of the mesh and return it as a polyline. 172 | 173 | ```python 174 | import potpourri3d as pp3d 175 | 176 | V, F = # your mesh 177 | tracer = pp3d.GeodesicTracer(V,F) # shares precomputation for repeated traces 178 | 179 | trace_pts = tracer.trace_geodesic_from_vertex(22, np.array((0.3, 0.5, 0.4))) 180 | # trace_pts is a Vx3 numpy array of points forming the path 181 | ``` 182 | 183 | - `GeodesicTracer(V, F)` construct an instance of the tracer class. 184 | - `V` a Nx3 real numpy array of vertices 185 | - `F` a Mx3 integer numpy array of faces, with 0-based vertex indices (must form a manifold, oriented triangle mesh). 186 | - `GeodesicTracer.trace_geodesic_from_vertex(start_vert, direction_xyz, max_iterations=None)` trace a geodesic from `start_vert`. `direction_xyz` is a length-3 vector giving the direction to walk trace in 3D xyz coordinates, it will be projected onto the tangent space of the vertex. The magnitude of `direction_xyz` determines the distance walked. Output is an `Nx3` numpy array of positions which define the path as a polyline along the surface. 187 | - `GeodesicTracer.trace_geodesic_from_face(start_face, bary_coords, direction_xyz, max_iterations=None)` similar to above, but from a point in a face. `bary_coords` is a length-3 vector of barycentric coordinates giving the location within the face to start from. 188 | 189 | Set `max_iterations` to terminate early after tracing the path through some number of faces/edges (default: no limit). 190 | 191 | ### Point Cloud Distance & Vector Heat 192 | 193 | Use the [heat method for geodesic distance](https://www.cs.cmu.edu/~kmcrane/Projects/HeatMethod/) and [vector heat method](https://nmwsharp.com/research/vector-heat-method/) to compute various interpolation & vector-based quantities on point clouds. Repeated solves are fast after initial setup. 194 | 195 | ![point cloud vector heat examples](https://github.com/nmwsharp/potpourri3d/blob/master/media/point_heat_solvers.jpg) 196 | 197 | ```python 198 | import potpourri3d as pp3d 199 | 200 | # = Stateful solves 201 | P = # a Nx3 numpy array of points 202 | solver = pp3d.PointCloudHeatSolver(P) 203 | 204 | # Compute the geodesic distance to point 4 205 | dists = solver.compute_distance(4) 206 | 207 | # Extend the value `0.` from point 12 and `1.` from point 17. Any point 208 | # geodesically closer to 12. will take the value 0., and vice versa 209 | # (plus some slight smoothing) 210 | ext = solver.extend_scalar([12, 17], [0.,1.]) 211 | 212 | # Get the tangent frames which are used by the solver to define tangent data 213 | # at each point 214 | basisX, basisY, basisN = solver.get_tangent_frames() 215 | 216 | # Parallel transport a vector along the surface 217 | # (and map it to a vector in 3D) 218 | sourceP = 22 219 | ext = solver.transport_tangent_vector(sourceP, [6., 6.]) 220 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 221 | 222 | # Compute the logarithmic map 223 | logmap = solver.compute_log_map(sourceP) 224 | ``` 225 | 226 | - `PointCloudHeatSolver(P, t_coef=1.)` construct an instance of the solver class. 227 | - `P` a Nx3 real numpy array of points 228 | - `t_coef` set the time used for short-time heat flow. Generally don't change this. If necessary, larger values may make the solution more stable at the cost of smoothing it out. 229 | - `PointCloudHeatSolver.extend_scalar(p_inds, values)` nearest-geodesic-neighbor interpolate values defined at points. Points will take the value from the closest source point (plus some slight smoothing) 230 | - `v_inds` a list of source points 231 | - `values` a list of scalar values, one for each source points 232 | - `PointCloudHeatSolver.get_tangent_frames()` get the coordinate frames used to define tangent data at each point. Returned as a tuple of basis-X, basis-Y, and normal axes, each as an Nx3 array. May be necessary for change-of-basis into or out of tangent vector convention. 233 | - `PointCloudHeatSolver.transport_tangent_vector(p_ind, vector)` parallel transports a single vector across a surface 234 | - `p_ind` index of the source point 235 | - `vector` a 2D tangent vector to transport 236 | - `PointCloudHeatSolver.transport_tangent_vectors(p_inds, vectors)` parallel transports a collection of vectors across a surface, such that each vertex takes the vector from its nearest-geodesic-neighbor. 237 | - `p_inds` a list of source points 238 | - `vectors` a list of 2D tangent vectors, one for each source point 239 | - `PointCloudHeatSolver.compute_log_map(p_ind)` compute the logarithmic map centered at the given source point 240 | - `p_ind` index of the source point 241 | 242 | 243 | 244 | ### Other Point Cloud Routines 245 | 246 | #### Local Triangulation 247 | 248 | Construct a _local triangulation_ of a point cloud, a surface-like set of triangles amongst the points in the cloud. This is _not_ a nice connected/watertight mesh, instead it is a crazy soup, which is a union of sets of triangles computed independently around each point. These triangles _are_ suitable for running many geometric algorithms on, such as approximating surface properties of the point cloud, evaluating physical and geometric energies, or building Laplace matrices. See "A Laplacian for Nonmanifold Triangle Meshes", Sharp & Crane 2020, Sec 5.7 for details. 249 | 250 | - `PointCloudLocalTriangulation(P, with_degeneracy_heuristic=True)` 251 | - `PointCloudLocalTriangulation.get_local_triangulation()` returns a `[V,M,3]` integer numpy array, holding indices of vertices which form the triangulation. Each `[i,:,:]` holds the local triangles about vertex `i`. `M` is the max number of neighbors in any local triangulation. For vertices with fewer neighbors, the trailing rows hold `-1`. 252 | -------------------------------------------------------------------------------- /media/elephant_geodesic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nmwsharp/potpourri3d/29e7aeac77b10c0301418100c72919b3936c9850/media/elephant_geodesic.jpg -------------------------------------------------------------------------------- /media/point_heat_solvers.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nmwsharp/potpourri3d/29e7aeac77b10c0301418100c72919b3936c9850/media/point_heat_solvers.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "potpourri3d" 3 | version = "1.2.1" 4 | description = "An invigorating blend of 3D geometry tools in Python." 5 | readme = "README.md" 6 | license.file = "LICENSE" 7 | authors = [ 8 | { name = "Nicholas Sharp", email = "nmwsharp@gmail.com" }, 9 | ] 10 | maintainers = [ 11 | { name = "Nicholas Sharp", email = "nmwsharp@gmail.com" }, 12 | ] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python :: 3", 17 | ] 18 | requires-python = ">=3.7" 19 | 20 | dependencies = [ 21 | "numpy", 22 | "scipy" 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/nmwsharp/potpourri3d" 27 | 28 | [build-system] 29 | requires = ["scikit-build-core"] 30 | build-backend = "scikit_build_core.build" 31 | 32 | [tool.scikit-build] 33 | build.verbose = true 34 | logging.level = "INFO" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import platform 5 | import subprocess 6 | 7 | import setuptools 8 | from setuptools import setup, Extension 9 | from setuptools.command.build_ext import build_ext 10 | from distutils.version import LooseVersion 11 | 12 | __version__ = '1.2.0' 13 | 14 | class CMakeExtension(Extension): 15 | def __init__(self, name, sourcedir='', exclude_arch=False): 16 | Extension.__init__(self, name, sources=[]) 17 | self.sourcedir = os.path.abspath(sourcedir) 18 | self.exclude_arch = exclude_arch 19 | 20 | 21 | class CMakeBuild(build_ext): 22 | def run(self): 23 | try: 24 | out = subprocess.check_output(['cmake', '--version']) 25 | except OSError: 26 | raise RuntimeError("CMake must be installed to build the following extensions: " + 27 | ", ".join(e.name for e in self.extensions)) 28 | 29 | if platform.system() == "Windows": 30 | cmake_version = LooseVersion(re.search(r'version\s*([\d.]+)', out.decode()).group(1)) 31 | if cmake_version < '3.1.0': 32 | raise RuntimeError("CMake >= 3.1.0 is required on Windows") 33 | 34 | for ext in self.extensions: 35 | self.build_extension(ext) 36 | 37 | def build_extension(self, ext): 38 | extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name))) 39 | cmake_args = ['-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' + extdir, 40 | '-DPYTHON_EXECUTABLE=' + sys.executable] 41 | 42 | cfg = 'Debug' if self.debug else 'Release' 43 | build_args = ['--config', cfg] 44 | 45 | if platform.system() == "Windows": 46 | cmake_args += ['-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}'.format(cfg.upper(), extdir)] 47 | if not ext.exclude_arch: 48 | if sys.maxsize > 2**32: 49 | cmake_args += ['-A', 'x64'] 50 | else: 51 | cmake_args += ['-A', 'Win32'] 52 | build_args += ['--', '/m'] 53 | else: 54 | cmake_args += ['-DCMAKE_BUILD_TYPE=' + cfg] 55 | build_args += ['--', '-j3'] 56 | 57 | if self.distribution.verbose > 0: 58 | cmake_args += ['-DCMAKE_VERBOSE_MAKEFILE:BOOL=ON'] 59 | 60 | 61 | env = os.environ.copy() 62 | env['CXXFLAGS'] = '{} -DVERSION_INFO=\\"{}\\"'.format(env.get('CXXFLAGS', ''), 63 | self.distribution.get_version()) 64 | if not os.path.exists(self.build_temp): 65 | os.makedirs(self.build_temp) 66 | 67 | if(self.distribution.verbose > 0): 68 | print("Running cmake configure command: " + " ".join(['cmake', ext.sourcedir] + cmake_args)) 69 | subprocess.check_call(['cmake', ext.sourcedir] + cmake_args, cwd=self.build_temp, env=env) 70 | 71 | if(self.distribution.verbose > 0): 72 | print("Running cmake build command: " + " ".join(['cmake', '--build', '.'] + build_args)) 73 | subprocess.check_call(['cmake', '--build', '.'] + build_args, cwd=self.build_temp) 74 | 75 | def main(): 76 | 77 | with open('README.md') as f: 78 | long_description = f.read() 79 | 80 | # Applies to windows only. 81 | # Normally, we set cmake's -A option to specify 64 bit platform when need (and /m for build), 82 | # but these are errors with non-visual-studio generators. CMake does not seem to have an idiomatic 83 | # way to disable, so we expose an option here. A more robust solution would auto-detect based on the 84 | # generator. Really, this option might be better titled "exclude visual-studio-settings-on-windows" 85 | if "--exclude-arch" in sys.argv: 86 | exclude_arch = True 87 | sys.argv.remove('--exclude-arch') 88 | else: 89 | exclude_arch = False 90 | 91 | setup( 92 | name='potpourri3d', 93 | version=__version__, 94 | author='Nicholas Sharp', 95 | author_email='nmwsharp@gmail.com', 96 | url='https://github.com/nmwsharp/potpourri3d', 97 | description='An invigorating blend of 3D geometry tools in Python.', 98 | long_description=long_description, 99 | long_description_content_type='text/markdown', 100 | license="MIT", 101 | package_dir = {'': 'src'}, 102 | packages=setuptools.find_packages(where="src"), 103 | ext_modules=[CMakeExtension('.')], 104 | install_requires=['numpy','scipy'], 105 | cmdclass=dict(build_ext=CMakeBuild), 106 | zip_safe=False, 107 | test_suite="test", 108 | ) 109 | 110 | if __name__ == "__main__": 111 | main() 112 | -------------------------------------------------------------------------------- /src/cpp/.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | AlignAfterOpenBracket: Align 3 | AlignOperands: 'true' 4 | AllowShortBlocksOnASingleLine: 'false' 5 | AllowShortIfStatementsOnASingleLine: 'true' 6 | AllowShortLoopsOnASingleLine: 'true' 7 | AlwaysBreakTemplateDeclarations: 'true' 8 | BinPackParameters: 'true' 9 | BreakBeforeBraces: Attach 10 | ColumnLimit: '120' 11 | IndentWidth: '2' 12 | KeepEmptyLinesAtTheStartOfBlocks: 'true' 13 | MaxEmptyLinesToKeep: '2' 14 | PointerAlignment: Left 15 | ReflowComments: 'true' 16 | SpacesInAngles: 'false' 17 | SpacesInParentheses: 'false' 18 | SpacesInSquareBrackets: 'false' 19 | Standard: Cpp11 20 | UseTab: Never 21 | 22 | ... 23 | -------------------------------------------------------------------------------- /src/cpp/core.cpp: -------------------------------------------------------------------------------- 1 | #include "core.h" 2 | 3 | 4 | // Actual binding code 5 | PYBIND11_MODULE(potpourri3d_bindings, m) { 6 | m.doc() = "potpourri3d low-level bindings"; 7 | 8 | bind_io(m); 9 | bind_mesh(m); 10 | bind_point_cloud(m); 11 | } 12 | -------------------------------------------------------------------------------- /src/cpp/core.h: -------------------------------------------------------------------------------- 1 | #include "geometrycentral/pointcloud/point_cloud.h" 2 | #include "geometrycentral/surface/simple_polygon_mesh.h" 3 | #include "geometrycentral/surface/surface_mesh.h" 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | #include "Eigen/Dense" 10 | 11 | namespace py = pybind11; 12 | 13 | using namespace geometrycentral; 14 | using namespace geometrycentral::surface; 15 | using namespace geometrycentral::pointcloud; 16 | 17 | 18 | // Forward declare module builders in various source files 19 | void bind_io(py::module& m); 20 | void bind_mesh(py::module& m); 21 | void bind_point_cloud(py::module& m); 22 | -------------------------------------------------------------------------------- /src/cpp/io.cpp: -------------------------------------------------------------------------------- 1 | #include "geometrycentral/numerical/linear_algebra_utilities.h" 2 | #include "geometrycentral/pointcloud/point_cloud.h" 3 | #include "geometrycentral/pointcloud/point_cloud_io.h" 4 | #include "geometrycentral/pointcloud/point_position_geometry.h" 5 | #include "geometrycentral/surface/edge_length_geometry.h" 6 | #include "geometrycentral/surface/manifold_surface_mesh.h" 7 | #include "geometrycentral/surface/meshio.h" 8 | #include "geometrycentral/surface/simple_polygon_mesh.h" 9 | #include "geometrycentral/surface/surface_mesh.h" 10 | #include "geometrycentral/surface/surface_mesh_factories.h" 11 | #include "geometrycentral/surface/vertex_position_geometry.h" 12 | #include "geometrycentral/utilities/eigen_interop_helpers.h" 13 | 14 | #include 15 | #include 16 | #include 17 | 18 | #include "Eigen/Dense" 19 | 20 | namespace py = pybind11; 21 | 22 | using namespace geometrycentral; 23 | using namespace geometrycentral::surface; 24 | using namespace geometrycentral::pointcloud; 25 | 26 | 27 | // For overloaded functions, with C++11 compiler only 28 | template 29 | using overload_cast_ = pybind11::detail::overload_cast_impl; 30 | 31 | std::tuple, DenseMatrix> read_mesh(std::string filename) { 32 | 33 | // Call the mesh reader 34 | SimplePolygonMesh pmesh(filename); 35 | 36 | if (pmesh.nFaces() == 0) throw std::runtime_error("read mesh has no faces"); 37 | 38 | // Manually copy the vertex array 39 | DenseMatrix V(pmesh.nVertices(), 3); 40 | for (size_t i = 0; i < pmesh.nVertices(); i++) { 41 | for (size_t j = 0; j < 3; j++) { 42 | V(i, j) = pmesh.vertexCoordinates[i][j]; 43 | } 44 | } 45 | 46 | // Manually copy the face array 47 | size_t fDegree = pmesh.polygons[0].size(); 48 | DenseMatrix F(pmesh.nFaces(), fDegree); 49 | for (size_t i = 0; i < pmesh.nFaces(); i++) { 50 | if (pmesh.polygons[i].size() != fDegree) throw std::runtime_error("read mesh faces are not all the same degree"); 51 | for (size_t j = 0; j < fDegree; j++) { 52 | F(i, j) = pmesh.polygons[i][j]; 53 | } 54 | } 55 | 56 | 57 | return std::make_tuple(V, F); 58 | } 59 | 60 | namespace { // anonymous helers 61 | SimplePolygonMesh buildMesh(const DenseMatrix& verts, const DenseMatrix& faces, 62 | const DenseMatrix& corner_UVs) { 63 | std::vector coords(verts.rows()); 64 | for (size_t i = 0; i < verts.rows(); i++) { 65 | for (size_t j = 0; j < 3; j++) { 66 | coords[i][j] = verts(i, j); 67 | } 68 | } 69 | std::vector> polys(faces.rows()); 70 | for (size_t i = 0; i < faces.rows(); i++) { 71 | polys[i].resize(faces.cols()); 72 | for (size_t j = 0; j < faces.cols(); j++) { 73 | polys[i][j] = faces(i, j); 74 | } 75 | } 76 | std::vector> corner_params; 77 | if (corner_UVs.size() > 0) { 78 | corner_params.resize(faces.rows()); 79 | for (size_t i = 0; i < faces.rows(); i++) { 80 | corner_params[i].resize(faces.cols()); 81 | for (size_t j = 0; j < faces.cols(); j++) { 82 | size_t ind = i * faces.cols() + j; 83 | for (size_t k = 0; k < 2; k++) { 84 | corner_params[i][j][k] = corner_UVs(ind, k); 85 | } 86 | } 87 | } 88 | } 89 | 90 | return SimplePolygonMesh(polys, coords, corner_params); 91 | } 92 | SimplePolygonMesh buildMesh(const DenseMatrix& verts, const DenseMatrix& faces) { 93 | DenseMatrix empty_UVs = DenseMatrix::Zero(0, 2); 94 | return buildMesh(verts, faces, empty_UVs); 95 | } 96 | } // namespace 97 | 98 | void write_mesh(DenseMatrix verts, DenseMatrix faces, std::string filename) { 99 | SimplePolygonMesh pmesh = buildMesh(verts, faces); 100 | pmesh.writeMesh(filename); 101 | } 102 | 103 | void write_mesh_pervertex_uv(DenseMatrix verts, DenseMatrix faces, DenseMatrix UVs, 104 | std::string filename) { 105 | size_t V = verts.rows(); 106 | size_t F = faces.rows(); 107 | size_t D = faces.cols(); 108 | 109 | // expand out to per-corner UVs 110 | DenseMatrix face_UVs = DenseMatrix::Zero(F * D, 2); 111 | for (size_t i = 0; i < F; i++) { 112 | for (size_t j = 0; j < D; j++) { 113 | size_t vInd = faces(i, j); 114 | for (size_t k = 0; k < 2; k++) { 115 | face_UVs(i * D + j, k) = UVs(vInd, k); 116 | } 117 | } 118 | } 119 | 120 | SimplePolygonMesh pmesh = buildMesh(verts, faces, face_UVs); 121 | pmesh.writeMesh(filename); 122 | } 123 | 124 | void write_mesh_perface_uv(DenseMatrix verts, DenseMatrix faces, DenseMatrix UVs, 125 | std::string filename) { 126 | 127 | size_t V = verts.rows(); 128 | size_t F = faces.rows(); 129 | size_t D = faces.cols(); 130 | 131 | // expand out to per-corner UVs 132 | DenseMatrix face_UVs = DenseMatrix::Zero(F * D, 2); 133 | for (size_t i = 0; i < F; i++) { 134 | for (size_t j = 0; j < D; j++) { 135 | for (size_t k = 0; k < 2; k++) { 136 | face_UVs(i * D + j, k) = UVs(i, k); 137 | } 138 | } 139 | } 140 | 141 | SimplePolygonMesh pmesh = buildMesh(verts, faces, face_UVs); 142 | pmesh.writeMesh(filename); 143 | } 144 | 145 | void write_mesh_percorner_uv(DenseMatrix verts, DenseMatrix faces, DenseMatrix UVs, 146 | std::string filename) { 147 | SimplePolygonMesh pmesh = buildMesh(verts, faces, UVs); 148 | pmesh.writeMesh(filename); 149 | } 150 | 151 | DenseMatrix read_point_cloud(std::string filename) { 152 | 153 | std::unique_ptr cloud; 154 | std::unique_ptr geom; 155 | std::tie(cloud, geom) = readPointCloud(filename); 156 | 157 | return EigenMap(geom->positions); 158 | } 159 | 160 | void write_point_cloud(DenseMatrix points, std::string filename) { 161 | 162 | // Copy in to the point cloud object 163 | PointCloud cloud(points.rows()); 164 | PointPositionGeometry geom(cloud); 165 | for (size_t i = 0; i < points.rows(); i++) { 166 | for (size_t j = 0; j < 3; j++) { 167 | geom.positions[i][j] = points(i, j); 168 | } 169 | } 170 | 171 | // Call the writer 172 | writePointCloud(cloud, geom, filename); 173 | } 174 | 175 | 176 | // Actual binding code 177 | // clang-format off 178 | void bind_io(py::module& m) { 179 | 180 | m.def("read_mesh", &read_mesh, "Read a mesh from file.", py::arg("filename")); 181 | 182 | m.def("write_mesh", &write_mesh, "Write a mesh to file.", py::arg("verts"), py::arg("faces"), py::arg("filename")); 183 | m.def("write_mesh_pervertex_uv", &write_mesh_pervertex_uv, "Write a mesh to file.", py::arg("verts"), py::arg("faces"), py::arg("UVs"), py::arg("filename")); 184 | m.def("write_mesh_perface_uv", &write_mesh_perface_uv, "Write a mesh to file.", py::arg("verts"), py::arg("faces"), py::arg("UVs"), py::arg("filename")); 185 | m.def("write_mesh_percorner_uv", &write_mesh_percorner_uv, "Write a mesh to file.", py::arg("verts"), py::arg("faces"), py::arg("UVs"), py::arg("filename")); 186 | 187 | m.def("read_point_cloud", &read_point_cloud, "Read a point cloud from file.", py::arg("filename")); 188 | m.def("write_point_cloud", &write_point_cloud, "Write a point cloud to file.", py::arg("points"), py::arg("filename")); 189 | } 190 | -------------------------------------------------------------------------------- /src/cpp/mesh.cpp: -------------------------------------------------------------------------------- 1 | #include "geometrycentral/numerical/linear_algebra_utilities.h" 2 | #include "geometrycentral/surface/edge_length_geometry.h" 3 | #include "geometrycentral/surface/flip_geodesics.h" 4 | #include "geometrycentral/surface/heat_method_distance.h" 5 | #include "geometrycentral/surface/manifold_surface_mesh.h" 6 | #include "geometrycentral/surface/mesh_graph_algorithms.h" 7 | #include "geometrycentral/surface/simple_polygon_mesh.h" 8 | #include "geometrycentral/surface/surface_mesh.h" 9 | #include "geometrycentral/surface/surface_mesh_factories.h" 10 | #include "geometrycentral/surface/trace_geodesic.h" 11 | #include "geometrycentral/surface/vector_heat_method.h" 12 | #include "geometrycentral/surface/vertex_position_geometry.h" 13 | #include "geometrycentral/utilities/eigen_interop_helpers.h" 14 | 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #include "Eigen/Dense" 21 | 22 | namespace py = pybind11; 23 | 24 | using namespace geometrycentral; 25 | using namespace geometrycentral::surface; 26 | 27 | 28 | // For overloaded functions, with C++11 compiler only 29 | template 30 | using overload_cast_ = pybind11::detail::overload_cast_impl; 31 | 32 | 33 | // A wrapper class for the heat method solver, which exposes Eigen in/out 34 | class HeatMethodDistanceEigen { 35 | 36 | public: 37 | HeatMethodDistanceEigen(DenseMatrix verts, DenseMatrix faces, double tCoef = 1.0, 38 | bool useRobustLaplacian = true) { 39 | 40 | // Construct the internal mesh and geometry 41 | mesh.reset(new SurfaceMesh(faces)); 42 | geom.reset(new VertexPositionGeometry(*mesh)); 43 | for (size_t i = 0; i < mesh->nVertices(); i++) { 44 | for (size_t j = 0; j < 3; j++) { 45 | geom->inputVertexPositions[i][j] = verts(i, j); 46 | } 47 | } 48 | 49 | // Build the solver 50 | solver.reset(new HeatMethodDistanceSolver(*geom, tCoef, useRobustLaplacian)); 51 | } 52 | 53 | // Solve for distance from a single vertex 54 | Vector compute_distance(int64_t sourceVert) { 55 | VertexData dist = solver->computeDistance(mesh->vertex(sourceVert)); 56 | return dist.toVector(); 57 | } 58 | 59 | // Solve for distance from a collection of vertices 60 | Vector compute_distance_multisource(Vector sourceVerts) { 61 | std::vector sources; 62 | for (size_t i = 0; i < sourceVerts.rows(); i++) { 63 | sources.push_back(mesh->vertex(sourceVerts(i))); 64 | } 65 | VertexData dist = solver->computeDistance(sources); 66 | return dist.toVector(); 67 | } 68 | 69 | private: 70 | std::unique_ptr mesh; 71 | std::unique_ptr geom; 72 | std::unique_ptr solver; 73 | }; 74 | 75 | 76 | // A wrapper class for the vector heat method solver, which exposes Eigen in/out 77 | class VectorHeatMethodEigen { 78 | 79 | // TODO use intrinsic triangulations here 80 | 81 | public: 82 | VectorHeatMethodEigen(DenseMatrix verts, DenseMatrix faces, double tCoef = 1.0) { 83 | 84 | // Construct the internal mesh and geometry 85 | mesh.reset(new ManifoldSurfaceMesh(faces)); 86 | geom.reset(new VertexPositionGeometry(*mesh)); 87 | for (size_t i = 0; i < mesh->nVertices(); i++) { 88 | for (size_t j = 0; j < 3; j++) { 89 | geom->inputVertexPositions[i][j] = verts(i, j); 90 | } 91 | } 92 | 93 | // Build the solver 94 | solver.reset(new VectorHeatMethodSolver(*geom, tCoef)); 95 | } 96 | 97 | // Extend scalars from a collection of vertices 98 | Vector extend_scalar(Vector sourceVerts, Vector values) { 99 | std::vector> sources; 100 | for (size_t i = 0; i < sourceVerts.rows(); i++) { 101 | sources.emplace_back(mesh->vertex(sourceVerts(i)), values(i)); 102 | } 103 | VertexData ext = solver->extendScalar(sources); 104 | return ext.toVector(); 105 | } 106 | 107 | 108 | // Returns an extrinsic representation of the tangent frame being used internally, as X/Y/N vectors. 109 | std::tuple, DenseMatrix, DenseMatrix> get_tangent_frames() { 110 | 111 | // Just in case we don't already have it 112 | geom->requireVertexNormals(); 113 | geom->requireVertexTangentBasis(); 114 | 115 | // unpack 116 | VertexData basisX(*mesh); 117 | VertexData basisY(*mesh); 118 | for (Vertex v : mesh->vertices()) { 119 | basisX[v] = geom->vertexTangentBasis[v][0]; 120 | basisY[v] = geom->vertexTangentBasis[v][1]; 121 | } 122 | 123 | return std::tuple, DenseMatrix, DenseMatrix>( 124 | EigenMap(basisX), EigenMap(basisY), EigenMap(geom->vertexNormals)); 125 | } 126 | 127 | SparseMatrix> get_connection_laplacian() { 128 | geom->requireVertexConnectionLaplacian(); 129 | SparseMatrix> Lconn = geom->vertexConnectionLaplacian; 130 | geom->unrequireVertexConnectionLaplacian(); 131 | return Lconn; 132 | } 133 | 134 | // TODO think about how to pass tangent frames around 135 | DenseMatrix transport_tangent_vectors(Vector sourceVerts, DenseMatrix values) { 136 | 137 | // Pack it as a Vector2 138 | std::vector> sources; 139 | for (size_t i = 0; i < sourceVerts.rows(); i++) { 140 | sources.emplace_back(mesh->vertex(sourceVerts(i)), Vector2{values(i, 0), values(i, 1)}); 141 | } 142 | VertexData ext = solver->transportTangentVectors(sources); 143 | 144 | return EigenMap(ext); 145 | } 146 | 147 | DenseMatrix transport_tangent_vector(int64_t sourceVert, DenseMatrix values) { 148 | 149 | // Pack it as a Vector2 150 | std::vector> sources; 151 | sources.emplace_back(mesh->vertex(sourceVert), Vector2{values(0), values(1)}); 152 | VertexData ext = solver->transportTangentVectors(sources); 153 | 154 | return EigenMap(ext); 155 | } 156 | 157 | 158 | DenseMatrix compute_log_map(int64_t sourceVert) { 159 | return EigenMap(solver->computeLogMap(mesh->vertex(sourceVert))); 160 | } 161 | 162 | private: 163 | std::unique_ptr mesh; 164 | std::unique_ptr geom; 165 | std::unique_ptr solver; 166 | }; 167 | 168 | // A wrapper class for flip-based geodesics 169 | class EdgeFlipGeodesicsManager { 170 | 171 | public: 172 | EdgeFlipGeodesicsManager(DenseMatrix verts, DenseMatrix faces) { 173 | 174 | // Construct the internal mesh and geometry 175 | mesh.reset(new ManifoldSurfaceMesh(faces)); 176 | geom.reset(new VertexPositionGeometry(*mesh)); 177 | for (size_t i = 0; i < mesh->nVertices(); i++) { 178 | for (size_t j = 0; j < 3; j++) { 179 | geom->inputVertexPositions[i][j] = verts(i, j); 180 | } 181 | } 182 | 183 | // Build the solver 184 | flipNetwork.reset(new FlipEdgeNetwork(*mesh, *geom, {})); 185 | flipNetwork->posGeom = geom.get(); 186 | flipNetwork->supportRewinding = true; 187 | } 188 | 189 | // Generate a point-to-point geodesic by straightening a Dijkstra path 190 | DenseMatrix find_geodesic_path(int64_t startVert, int64_t endVert, size_t maxIterations = INVALID_IND, 191 | double maxRelativeLengthDecrease = 0.) { 192 | 193 | // Get an initial dijkstra path 194 | std::vector dijkstraPath = shortestEdgePath(*geom, mesh->vertex(startVert), mesh->vertex(endVert)); 195 | 196 | if (startVert == endVert) { 197 | throw std::runtime_error("start and end vert are same"); 198 | } 199 | if (dijkstraPath.empty()) { 200 | throw std::runtime_error("vertices lie on disconnected components of the surface"); 201 | } 202 | 203 | // Reinitialize the ede network to contain this path 204 | flipNetwork->reinitializePath({dijkstraPath}); 205 | 206 | // Straighten the path to geodesic 207 | flipNetwork->iterativeShorten(maxIterations, maxRelativeLengthDecrease); 208 | 209 | // Extract the path and store it in the vector 210 | std::vector path3D = flipNetwork->getPathPolyline3D().front(); 211 | DenseMatrix out(path3D.size(), 3); 212 | for (size_t i = 0; i < path3D.size(); i++) { 213 | for (size_t j = 0; j < 3; j++) { 214 | out(i, j) = path3D[i][j]; 215 | } 216 | } 217 | 218 | // Be kind, rewind 219 | flipNetwork->rewind(); 220 | 221 | return out; 222 | } 223 | 224 | // Generate a point-to-point geodesic by straightening a poly-geodesic path 225 | DenseMatrix find_geodesic_path_poly(std::vector verts, size_t maxIterations = INVALID_IND, 226 | double maxRelativeLengthDecrease = 0.) { 227 | 228 | // Convert to a list of vertices 229 | std::vector halfedges; 230 | 231 | for (size_t i = 0; i + 1 < verts.size(); i++) { 232 | Vertex vA = mesh->vertex(verts[i]); 233 | Vertex vB = mesh->vertex(verts[i + 1]); 234 | std::vector dijkstraPath = shortestEdgePath(*geom, vA, vB); 235 | 236 | // validate 237 | if (vA == vB) { 238 | throw std::runtime_error("consecutive vertices are same"); 239 | } 240 | if (dijkstraPath.empty()) { 241 | throw std::runtime_error("vertices lie on disconnected components of the surface"); 242 | } 243 | 244 | halfedges.insert(halfedges.end(), dijkstraPath.begin(), dijkstraPath.end()); 245 | } 246 | 247 | // Reinitialize the ede network to contain this path 248 | flipNetwork->reinitializePath({halfedges}); 249 | 250 | // Straighten the path to geodesic 251 | flipNetwork->iterativeShorten(maxIterations, maxRelativeLengthDecrease); 252 | 253 | // Extract the path and store it in the vector 254 | std::vector path3D = flipNetwork->getPathPolyline3D().front(); 255 | DenseMatrix out(path3D.size(), 3); 256 | for (size_t i = 0; i < path3D.size(); i++) { 257 | for (size_t j = 0; j < 3; j++) { 258 | out(i, j) = path3D[i][j]; 259 | } 260 | } 261 | 262 | // Be kind, rewind 263 | flipNetwork->rewind(); 264 | 265 | return out; 266 | } 267 | 268 | 269 | // Generate a point-to-point geodesic loop by straightening a poly-geodesic path 270 | DenseMatrix find_geodesic_loop(std::vector verts, size_t maxIterations = INVALID_IND, 271 | double maxRelativeLengthDecrease = 0.) { 272 | 273 | // Convert to a list of vertices 274 | std::vector halfedges; 275 | 276 | for (size_t i = 0; i < verts.size(); i++) { 277 | Vertex vA = mesh->vertex(verts[i]); 278 | Vertex vB = mesh->vertex(verts[(i + 1) % verts.size()]); 279 | std::vector dijkstraPath = shortestEdgePath(*geom, vA, vB); 280 | 281 | // validate 282 | if (vA == vB) { 283 | throw std::runtime_error("consecutive vertices are same"); 284 | } 285 | if (dijkstraPath.empty()) { 286 | throw std::runtime_error("vertices lie on disconnected components of the surface"); 287 | } 288 | 289 | halfedges.insert(halfedges.end(), dijkstraPath.begin(), dijkstraPath.end()); 290 | } 291 | 292 | // Reinitialize the ede network to contain this path 293 | flipNetwork->reinitializePath({halfedges}); 294 | 295 | // Straighten the path to geodesic 296 | flipNetwork->iterativeShorten(maxIterations, maxRelativeLengthDecrease); 297 | 298 | // Extract the path and store it in the vector 299 | std::vector path3D = flipNetwork->getPathPolyline3D().front(); 300 | DenseMatrix out(path3D.size(), 3); 301 | for (size_t i = 0; i < path3D.size(); i++) { 302 | for (size_t j = 0; j < 3; j++) { 303 | out(i, j) = path3D[i][j]; 304 | } 305 | } 306 | 307 | // Be kind, rewind 308 | flipNetwork->rewind(); 309 | 310 | return out; 311 | } 312 | 313 | private: 314 | std::unique_ptr mesh; 315 | std::unique_ptr geom; 316 | std::unique_ptr flipNetwork; 317 | }; 318 | 319 | class GeodesicTracer { 320 | 321 | public: 322 | GeodesicTracer(DenseMatrix verts, DenseMatrix faces) { 323 | 324 | // Construct the internal mesh and geometry 325 | mesh.reset(new ManifoldSurfaceMesh(faces)); 326 | geom.reset(new VertexPositionGeometry(*mesh)); 327 | for (size_t i = 0; i < mesh->nVertices(); i++) { 328 | for (size_t j = 0; j < 3; j++) { 329 | geom->inputVertexPositions[i][j] = verts(i, j); 330 | } 331 | } 332 | 333 | geom->requireVertexTangentBasis(); 334 | geom->requireFaceTangentBasis(); 335 | } 336 | 337 | // Generate a geodesic by tracing from a vertex along a tangent direction 338 | DenseMatrix trace_geodesic_worker(SurfacePoint start_point, Vector2 start_dir, 339 | size_t max_iters = INVALID_IND) { 340 | 341 | TraceOptions opts; 342 | opts.includePath = true; 343 | opts.errorOnProblem = false; 344 | opts.barrierEdges = nullptr; 345 | opts.maxIters = max_iters; 346 | 347 | TraceGeodesicResult result = traceGeodesic(*geom, start_point, start_dir, opts); 348 | 349 | if (!result.hasPath) { 350 | throw std::runtime_error("geodesic trace encountered an error"); 351 | } 352 | 353 | // Extract the path and store it in the vector 354 | DenseMatrix out(result.pathPoints.size(), 3); 355 | for (size_t i = 0; i < result.pathPoints.size(); i++) { 356 | Vector3 point = result.pathPoints[i].interpolate(geom->vertexPositions); 357 | for (size_t j = 0; j < 3; j++) { 358 | out(i, j) = point[j]; 359 | } 360 | } 361 | 362 | return out; 363 | } 364 | 365 | // Generate a geodesic by tracing from a vertex along a tangent direction 366 | DenseMatrix trace_geodesic_from_vertex(int64_t startVert, Eigen::Vector3d direction_xyz, 367 | size_t max_iters = INVALID_IND) { 368 | 369 | // Project the input direction onto the tangent basis 370 | Vertex vert = mesh->vertex(startVert); 371 | Vector3 direction{direction_xyz(0), direction_xyz(1), direction_xyz(2)}; 372 | Vector3 bX = geom->vertexTangentBasis[vert][0]; 373 | Vector3 bY = geom->vertexTangentBasis[vert][1]; 374 | 375 | // magnitude matters! it determines the length 376 | Vector2 tangent_dir{dot(direction, bX), dot(direction, bY)}; 377 | 378 | return trace_geodesic_worker(SurfacePoint(vert), tangent_dir, max_iters); 379 | } 380 | 381 | // Generate a geodesic by tracing from a face along a tangent direction 382 | DenseMatrix trace_geodesic_from_face(int64_t startFace, Eigen::Vector3d bary_coords, 383 | Eigen::Vector3d direction_xyz, size_t max_iters = INVALID_IND) { 384 | 385 | // Project the input direction onto the tangent basis 386 | Face face = mesh->face(startFace); 387 | Vector3 bary{bary_coords(0), bary_coords(1), bary_coords(2)}; 388 | Vector3 direction{direction_xyz(0), direction_xyz(1), direction_xyz(2)}; 389 | Vector3 bX = geom->faceTangentBasis[face][0]; 390 | Vector3 bY = geom->faceTangentBasis[face][1]; 391 | 392 | // magnitude matters! it determines the length 393 | Vector2 tangent_dir{dot(direction, bX), dot(direction, bY)}; 394 | 395 | return trace_geodesic_worker(SurfacePoint(face, bary), tangent_dir, max_iters); 396 | } 397 | 398 | private: 399 | std::unique_ptr mesh; 400 | std::unique_ptr geom; 401 | }; 402 | 403 | 404 | // Actual binding code 405 | // clang-format off 406 | void bind_mesh(py::module& m) { 407 | 408 | py::class_(m, "MeshHeatMethodDistance") 409 | .def(py::init, DenseMatrix, double, bool>()) 410 | .def("compute_distance", &HeatMethodDistanceEigen::compute_distance, py::arg("source_vert")) 411 | .def("compute_distance_multisource", &HeatMethodDistanceEigen::compute_distance_multisource, py::arg("source_verts")); 412 | 413 | 414 | py::class_(m, "MeshVectorHeatMethod") 415 | .def(py::init, DenseMatrix, double>()) 416 | .def("extend_scalar", &VectorHeatMethodEigen::extend_scalar, py::arg("source_verts"), py::arg("values")) 417 | .def("get_tangent_frames", &VectorHeatMethodEigen::get_tangent_frames) 418 | .def("get_connection_laplacian", &VectorHeatMethodEigen::get_connection_laplacian) 419 | .def("transport_tangent_vector", &VectorHeatMethodEigen::transport_tangent_vector, py::arg("source_vert"), py::arg("vector")) 420 | .def("transport_tangent_vectors", &VectorHeatMethodEigen::transport_tangent_vectors, py::arg("source_verts"), py::arg("vectors")) 421 | .def("compute_log_map", &VectorHeatMethodEigen::compute_log_map, py::arg("source_vert")); 422 | 423 | 424 | py::class_(m, "EdgeFlipGeodesicsManager") 425 | .def(py::init, DenseMatrix>()) 426 | .def("find_geodesic_path", &EdgeFlipGeodesicsManager::find_geodesic_path, py::arg("source_vert"), py::arg("target_vert"), py::arg("maxIterations"), py::arg("maxRelativeLengthDecrease")) 427 | .def("find_geodesic_path_poly", &EdgeFlipGeodesicsManager::find_geodesic_path_poly, py::arg("vert_list"), py::arg("maxIterations"), py::arg("maxRelativeLengthDecrease")) 428 | .def("find_geodesic_loop", &EdgeFlipGeodesicsManager::find_geodesic_loop, py::arg("vert_list"), py::arg("maxIterations"), py::arg("maxRelativeLengthDecrease")); 429 | 430 | 431 | py::class_(m, "GeodesicTracer") 432 | .def(py::init, DenseMatrix>()) 433 | .def("trace_geodesic_from_vertex", &GeodesicTracer::trace_geodesic_from_vertex, py::arg("start_vert"), py::arg("direction_xyz"), py::arg("max_iters")) 434 | .def("trace_geodesic_from_face", &GeodesicTracer::trace_geodesic_from_face, py::arg("start_face"), py::arg("bary_coords"), py::arg("direction_xyz"), py::arg("max_iters")); 435 | 436 | } 437 | -------------------------------------------------------------------------------- /src/cpp/point_cloud.cpp: -------------------------------------------------------------------------------- 1 | #include "geometrycentral/pointcloud/point_cloud.h" 2 | #include "geometrycentral/numerical/linear_algebra_utilities.h" 3 | #include "geometrycentral/pointcloud/point_cloud_heat_solver.h" 4 | #include "geometrycentral/pointcloud/point_cloud_io.h" 5 | #include "geometrycentral/pointcloud/point_position_geometry.h" 6 | #include "geometrycentral/pointcloud/local_triangulation.h" 7 | #include "geometrycentral/utilities/eigen_interop_helpers.h" 8 | 9 | #include 10 | #include 11 | #include 12 | 13 | #include "Eigen/Dense" 14 | 15 | 16 | namespace py = pybind11; 17 | 18 | using namespace geometrycentral; 19 | using namespace geometrycentral::surface; 20 | using namespace geometrycentral::pointcloud; 21 | 22 | 23 | // For overloaded functions, with C++11 compiler only 24 | template 25 | using overload_cast_ = pybind11::detail::overload_cast_impl; 26 | 27 | 28 | // A wrapper class for the heat method solver, which exposes Eigen in/out 29 | class PointCloudHeatSolverEigen { 30 | 31 | public: 32 | PointCloudHeatSolverEigen(DenseMatrix points, double tCoef = 1.0) { 33 | 34 | // Construct the internal cloud and geometry 35 | cloud.reset(new PointCloud(points.rows())); 36 | geom.reset(new PointPositionGeometry(*cloud)); 37 | for (size_t i = 0; i < cloud->nPoints(); i++) { 38 | for (size_t j = 0; j < 3; j++) { 39 | geom->positions[i][j] = points(i, j); 40 | } 41 | } 42 | 43 | // Build the solver 44 | solver.reset(new PointCloudHeatSolver(*cloud, *geom, tCoef)); 45 | } 46 | 47 | // Solve for distance from a single point 48 | Vector compute_distance(int64_t sourcePoint) { 49 | PointData dist = solver->computeDistance(cloud->point(sourcePoint)); 50 | return dist.toVector(); 51 | } 52 | 53 | // Solve for distance from a collection of points 54 | Vector compute_distance_multisource(Vector sourcePoints) { 55 | std::vector sources; 56 | for (size_t i = 0; i < sourcePoints.rows(); i++) { 57 | sources.push_back(cloud->point(sourcePoints(i))); 58 | } 59 | PointData dist = solver->computeDistance(sources); 60 | return dist.toVector(); 61 | } 62 | 63 | 64 | Vector extend_scalar(Vector sourcePoints, Vector values) { 65 | std::vector> sources; 66 | for (size_t i = 0; i < sourcePoints.rows(); i++) { 67 | sources.emplace_back(cloud->point(sourcePoints(i)), values(i)); 68 | } 69 | PointData ext = solver->extendScalars(sources); 70 | return ext.toVector(); 71 | } 72 | 73 | // Returns an extrinsic representation of the tangent frame being used internally, as X/Y/N vectors. 74 | std::tuple, DenseMatrix, DenseMatrix> get_tangent_frames() { 75 | 76 | // Just in case we don't already have it 77 | geom->requireNormals(); 78 | geom->requireTangentBasis(); 79 | 80 | // unpack 81 | PointData basisX(*cloud); 82 | PointData basisY(*cloud); 83 | for (Point v : cloud->points()) { 84 | basisX[v] = geom->tangentBasis[v][0]; 85 | basisY[v] = geom->tangentBasis[v][1]; 86 | } 87 | 88 | return std::tuple, DenseMatrix, DenseMatrix>( 89 | EigenMap(basisX), EigenMap(basisY), EigenMap(geom->normals)); 90 | } 91 | 92 | DenseMatrix transport_tangent_vectors(Vector sourcePoints, DenseMatrix values) { 93 | 94 | // Pack it as a Vector2 95 | std::vector> sources; 96 | for (size_t i = 0; i < sourcePoints.rows(); i++) { 97 | sources.emplace_back(cloud->point(sourcePoints(i)), Vector2{values(i, 0), values(i, 1)}); 98 | } 99 | PointData ext = solver->transportTangentVectors(sources); 100 | 101 | return EigenMap(ext); 102 | } 103 | 104 | DenseMatrix transport_tangent_vector(int64_t sourcePoint, DenseMatrix values) { 105 | 106 | // Pack it as a Vector2 107 | std::vector> sources; 108 | sources.emplace_back(cloud->point(sourcePoint), Vector2{values(0), values(1)}); 109 | PointData ext = solver->transportTangentVectors(sources); 110 | 111 | return EigenMap(ext); 112 | } 113 | 114 | 115 | DenseMatrix compute_log_map(int64_t sourcePoint) { 116 | return EigenMap(solver->computeLogMap(cloud->point(sourcePoint))); 117 | } 118 | 119 | private: 120 | std::unique_ptr cloud; 121 | std::unique_ptr geom; 122 | std::unique_ptr solver; 123 | }; 124 | 125 | // A class that exposes the local pointcloud trinagulation to python 126 | class PointCloudLocalTriangulation { 127 | public: 128 | PointCloudLocalTriangulation(DenseMatrix points, bool withDegeneracyHeuristic) : withDegeneracyHeuristic(withDegeneracyHeuristic) { 129 | 130 | // Construct the internal cloud and geometry 131 | cloud.reset(new PointCloud(points.rows())); 132 | geom.reset(new PointPositionGeometry(*cloud)); 133 | for (size_t i = 0; i < cloud->nPoints(); i++) { 134 | for (size_t j = 0; j < 3; j++) { 135 | geom->positions[i][j] = points(i, j); 136 | } 137 | } 138 | } 139 | 140 | 141 | Eigen::Matrix get_local_triangulation() { 142 | PointData>> local_triangulation = buildLocalTriangulations(*cloud, *geom, withDegeneracyHeuristic); 143 | 144 | int max_neigh = 0; 145 | 146 | size_t idx = 0; 147 | for (Point v : cloud->points()) { 148 | max_neigh = std::max(max_neigh, static_cast(local_triangulation[v].size())); 149 | if (idx != v.getIndex()) { 150 | py::print("Error. Index of points not consistent. (Idx, v.getIndex) = ", idx, v.getIndex()); 151 | } 152 | idx++; 153 | } 154 | 155 | Eigen::Matrix out(cloud->nPoints(), 3 * max_neigh); 156 | out.setConstant(-1); 157 | 158 | for (Point v : cloud->points()) { 159 | int i = 0; 160 | for (auto const &neighs : local_triangulation[v]) { 161 | out(v.getIndex(), i + 0) = (int) neighs[0].getIndex(); 162 | out(v.getIndex(), i + 1) = (int) neighs[1].getIndex(); 163 | out(v.getIndex(), i + 2) = (int) neighs[2].getIndex(); 164 | i += 3; 165 | } 166 | } 167 | 168 | return out; 169 | } 170 | 171 | private: 172 | const bool withDegeneracyHeuristic; 173 | std::unique_ptr cloud; 174 | std::unique_ptr geom; 175 | std::unique_ptr solver; 176 | }; 177 | 178 | 179 | // Actual binding code 180 | // clang-format off 181 | void bind_point_cloud(py::module& m) { 182 | 183 | py::class_(m, "PointCloudHeatSolver") 184 | .def(py::init, double>()) 185 | .def("compute_distance", &PointCloudHeatSolverEigen::compute_distance, py::arg("source_point")) 186 | .def("compute_distance_multisource", &PointCloudHeatSolverEigen::compute_distance_multisource, py::arg("source_points")) 187 | .def("extend_scalar", &PointCloudHeatSolverEigen::extend_scalar, py::arg("source_points"), py::arg("source_values")) 188 | .def("get_tangent_frames", &PointCloudHeatSolverEigen::get_tangent_frames) 189 | .def("transport_tangent_vector", &PointCloudHeatSolverEigen::transport_tangent_vector, py::arg("source_point"), py::arg("vector")) 190 | .def("transport_tangent_vectors", &PointCloudHeatSolverEigen::transport_tangent_vectors, py::arg("source_points"), py::arg("vectors")) 191 | .def("compute_log_map", &PointCloudHeatSolverEigen::compute_log_map, py::arg("source_point")); 192 | 193 | py::class_(m, "PointCloudLocalTriangulation") 194 | .def(py::init, bool>()) 195 | .def("get_local_triangulation", &PointCloudLocalTriangulation::get_local_triangulation); 196 | } 197 | -------------------------------------------------------------------------------- /src/potpourri3d/__init__.py: -------------------------------------------------------------------------------- 1 | from potpourri3d.core import * 2 | from potpourri3d.io import * 3 | from potpourri3d.mesh import * 4 | from potpourri3d.point_cloud import * 5 | -------------------------------------------------------------------------------- /src/potpourri3d/core.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import potpourri3d_bindings as pp3db 3 | 4 | # Sanity checkers applied throughout 5 | 6 | def validate_mesh(V, F, force_triangular=False, test_indices=False): 7 | 8 | if len(V.shape) != 2 or V.shape[1] != 3: 9 | raise ValueError("vertices should be a 2d Nx3 numpy array") 10 | n_vert = V.shape[0] 11 | 12 | if len(F.shape) != 2 or F.shape[1] < 3: 13 | raise ValueError("faces should be a 2d NxD numpy array, where D >= 3") 14 | 15 | if force_triangular and F.shape[1] != 3: 16 | raise ValueError("faces must be triangular; dimensions should be Nx3") 17 | 18 | if test_indices: 19 | max_elem = np.amin(F) 20 | if max_elem >= n_vert: 21 | raise ValueError("There is an out-of-bounds face index. Faces should be zero-based array of indices to vertices") 22 | 23 | def validate_points(V): 24 | 25 | if len(V.shape) != 2 or V.shape[1] != 3: 26 | raise ValueError("vertices should be a 2d Nx3 numpy array") 27 | -------------------------------------------------------------------------------- /src/potpourri3d/io.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import potpourri3d_bindings as pp3db 3 | 4 | from .core import * 5 | 6 | def read_mesh(filename): 7 | V, F = pp3db.read_mesh(filename) 8 | V = np.ascontiguousarray(V) 9 | F = np.ascontiguousarray(F) 10 | return V, F 11 | 12 | def write_mesh(V, F, filename, UV_coords=None, UV_type=None): 13 | # TODO generalize this to take indexed UVs 14 | # (the underlying geometry-central writer needs to support it first) 15 | 16 | validate_mesh(V, F, test_indices=True) 17 | 18 | if UV_type is None: 19 | 20 | pp3db.write_mesh(V, F, filename) 21 | 22 | elif UV_type == 'per-vertex': 23 | 24 | if len(UV_coords.shape) != 2 or UV_coords.shape[0] != V.shape[0] or UV_coords.shape[1] != 2: 25 | raise ValueError("UV_coords should be a 2d Vx2 numpy array") 26 | 27 | pp3db.write_mesh_pervertex_uv(V, F, UV_coords, filename) 28 | 29 | elif UV_type == 'per-face': 30 | 31 | if len(UV_coords.shape) != 2 or UV_coords.shape[0] != F.shape[0] or UV_coords.shape[1] != 2: 32 | raise ValueError("UV_coords should be a 2d Fx2 numpy array") 33 | 34 | pp3db.write_mesh_perface_uv(V, F, UV_coords, filename) 35 | 36 | elif UV_type == 'per-corner': 37 | 38 | if len(UV_coords.shape) != 2 or UV_coords.shape[0] != F.shape[0]*F.shape[1] or UV_coords.shape[1] != 2: 39 | raise ValueError("UV_coords should be a 2d Fx2 numpy array") 40 | 41 | pp3db.write_mesh_percorner_uv(V, F, UV_coords, filename) 42 | 43 | else: 44 | raise ValueError(f"unrecognized value for UV_type: {UV_type}. Should be one of: [None, 'per-vertex', 'per-face', 'per-corner']") 45 | 46 | 47 | def read_point_cloud(filename): 48 | V = pp3db.read_point_cloud(filename) 49 | V = np.ascontiguousarray(V) 50 | return V 51 | 52 | def write_point_cloud(V, filename): 53 | validate_points(V) 54 | pp3db.write_point_cloud(V, filename) 55 | -------------------------------------------------------------------------------- /src/potpourri3d/mesh.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import potpourri3d_bindings as pp3db 3 | 4 | import scipy 5 | import scipy.sparse 6 | 7 | from .core import * 8 | 9 | class MeshHeatMethodDistanceSolver(): 10 | 11 | def __init__(self, V, F, t_coef=1., use_robust=True): 12 | validate_mesh(V, F, force_triangular=True, test_indices=True) 13 | self.bound_solver = pp3db.MeshHeatMethodDistance(V, F, t_coef, use_robust) 14 | 15 | def compute_distance(self, v_ind): 16 | return self.bound_solver.compute_distance(v_ind) 17 | 18 | def compute_distance_multisource(self, v_inds): 19 | return self.bound_solver.compute_distance_multisource(v_inds) 20 | 21 | def compute_distance(V, F, v_ind): 22 | solver = MeshHeatMethodDistanceSolver(V, F) 23 | return solver.compute_distance(v_ind) 24 | 25 | def compute_distance_multisource(V, F, v_inds): 26 | solver = MeshHeatMethodDistanceSolver(V, F) 27 | return solver.compute_distance_multisource(v_inds) 28 | 29 | 30 | class MeshVectorHeatSolver(): 31 | 32 | def __init__(self, V, F, t_coef=1.): 33 | validate_mesh(V, F, force_triangular=True, test_indices=True) 34 | self.bound_solver = pp3db.MeshVectorHeatMethod(V, F, t_coef) 35 | 36 | def extend_scalar(self, v_inds, values): 37 | if len(v_inds) != len(values): 38 | raise ValueError("source vertex indices and values array should be same shape") 39 | return self.bound_solver.extend_scalar(v_inds, values) 40 | 41 | def get_tangent_frames(self): 42 | return self.bound_solver.get_tangent_frames() 43 | 44 | def get_connection_laplacian(self): 45 | return self.bound_solver.get_connection_laplacian() 46 | 47 | def transport_tangent_vector(self, v_ind, vector): 48 | if len(vector) != 2: 49 | raise ValueError("vector should be a 2D tangent vector") 50 | return self.bound_solver.transport_tangent_vector(v_ind, vector) 51 | 52 | def transport_tangent_vectors(self, v_inds, vectors): 53 | if len(v_inds) != len(vectors): 54 | raise ValueError("source vertex indices and values array should be same length") 55 | return self.bound_solver.transport_tangent_vectors(v_inds, vectors) 56 | 57 | def compute_log_map(self, v_ind): 58 | return self.bound_solver.compute_log_map(v_ind) 59 | 60 | class EdgeFlipGeodesicSolver(): 61 | 62 | def __init__(self, V, F, t_coef=1.): 63 | validate_mesh(V, F, force_triangular=True) 64 | self.bound_solver = pp3db.EdgeFlipGeodesicsManager(V, F) 65 | 66 | def find_geodesic_path(self, v_start, v_end, max_iterations=None, max_relative_length_decrease=None): 67 | if max_iterations is None: 68 | max_iterations = 2**63-1 69 | if max_relative_length_decrease is None: 70 | max_relative_length_decrease = 0. 71 | 72 | return self.bound_solver.find_geodesic_path(v_start, v_end, max_iterations, max_relative_length_decrease) 73 | 74 | def find_geodesic_path_poly(self, v_list, max_iterations=None, max_relative_length_decrease=None): 75 | if max_iterations is None: 76 | max_iterations = 2**63-1 77 | if max_relative_length_decrease is None: 78 | max_relative_length_decrease = 0. 79 | 80 | return self.bound_solver.find_geodesic_path_poly(v_list, max_iterations, max_relative_length_decrease) 81 | 82 | def find_geodesic_loop(self, v_list, max_iterations=None, max_relative_length_decrease=None): 83 | if max_iterations is None: 84 | max_iterations = 2**63-1 85 | if max_relative_length_decrease is None: 86 | max_relative_length_decrease = 0. 87 | 88 | return self.bound_solver.find_geodesic_loop(v_list, max_iterations, max_relative_length_decrease) 89 | 90 | class GeodesicTracer(): 91 | 92 | def __init__(self, V, F, t_coef=1.): 93 | validate_mesh(V, F, force_triangular=True) 94 | self.bound_tracer = pp3db.GeodesicTracer(V, F) 95 | 96 | def trace_geodesic_from_vertex(self, start_vert, direction_xyz, max_iterations=None): 97 | if max_iterations is None: 98 | max_iterations = 2**63-1 99 | 100 | direction_xyz = np.array(direction_xyz) 101 | 102 | return self.bound_tracer.trace_geodesic_from_vertex(start_vert, direction_xyz, max_iterations) 103 | 104 | def trace_geodesic_from_face(self, start_face, bary_coords, direction_xyz, max_iterations=None): 105 | if max_iterations is None: 106 | max_iterations = 2**63-1 107 | 108 | bary_coords = np.array(bary_coords) 109 | direction_xyz = np.array(direction_xyz) 110 | 111 | return self.bound_tracer.trace_geodesic_from_face(start_face, bary_coords, direction_xyz, max_iterations) 112 | 113 | 114 | def cotan_laplacian(V, F, denom_eps=0.): 115 | validate_mesh(V, F, force_triangular=True) 116 | nV = V.shape[0] 117 | 118 | mat_i = [] 119 | mat_j = [] 120 | mat_data = [] 121 | for i in range(3): 122 | 123 | # Gather indices and compute cotan weight (via dot() / cross() formula) 124 | inds_i = F[:,i] 125 | inds_j = F[:,(i+1)%3] 126 | inds_k = F[:,(i+2)%3] 127 | vec_ki = V[inds_i,:] - V[inds_k,:] 128 | vec_kj = V[inds_j,:] - V[inds_k,:] 129 | dots = np.sum(vec_ki * vec_kj, axis=1) 130 | cross_mags = np.linalg.norm(np.cross(vec_ki, vec_kj), axis=1) 131 | cotans = 0.5 * dots / (cross_mags + denom_eps) 132 | 133 | # Add the four matrix entries from this weight 134 | 135 | mat_i.append(inds_i) 136 | mat_j.append(inds_i) 137 | mat_data.append(cotans) 138 | 139 | mat_i.append(inds_j) 140 | mat_j.append(inds_j) 141 | mat_data.append(cotans) 142 | 143 | mat_i.append(inds_i) 144 | mat_j.append(inds_j) 145 | mat_data.append(-cotans) 146 | 147 | mat_i.append(inds_j) 148 | mat_j.append(inds_i) 149 | mat_data.append(-cotans) 150 | 151 | # Concatenate the arrays to single lists 152 | mat_i = np.concatenate(mat_i) 153 | mat_j = np.concatenate(mat_j) 154 | mat_data = np.concatenate(mat_data) 155 | 156 | L_coo = scipy.sparse.coo_matrix((mat_data, (mat_i, mat_j)), shape=(nV, nV)) 157 | 158 | return L_coo.tocsr() 159 | 160 | def face_areas(V, F): 161 | validate_mesh(V, F, force_triangular=True) 162 | 163 | vec_ij = V[F[:,1],:] - V[F[:,0],:] 164 | vec_ik = V[F[:,2],:] - V[F[:,0],:] 165 | 166 | areas = 0.5 * np.linalg.norm(np.cross(vec_ij, vec_ik), axis=1) 167 | 168 | return areas 169 | 170 | def vertex_areas(V, F): 171 | validate_mesh(V, F, force_triangular=True) 172 | nV = V.shape[0] 173 | 174 | face_area = face_areas(V, F) 175 | 176 | vertex_area = np.zeros(V.shape[0]) 177 | for i in range(3): 178 | vertex_area += np.bincount(F[:,i], face_area, minlength=nV) 179 | vertex_area /= 3. 180 | 181 | return vertex_area 182 | -------------------------------------------------------------------------------- /src/potpourri3d/point_cloud.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import potpourri3d_bindings as pp3db 3 | 4 | from .core import * 5 | 6 | class PointCloudHeatSolver(): 7 | 8 | def __init__(self, P, t_coef=1.): 9 | validate_points(P) 10 | self.bound_solver = pp3db.PointCloudHeatSolver(P, t_coef) 11 | 12 | def compute_distance(self, p_ind): 13 | return self.bound_solver.compute_distance(p_ind) 14 | 15 | def compute_distance_multisource(self, p_inds): 16 | return self.bound_solver.compute_distance_multisource(p_inds) 17 | 18 | def extend_scalar(self, p_inds, values): 19 | if len(p_inds) != len(values): 20 | raise ValueError("source point indices and values array should be same shape") 21 | return self.bound_solver.extend_scalar(p_inds, values) 22 | 23 | def get_tangent_frames(self): 24 | return self.bound_solver.get_tangent_frames() 25 | 26 | def transport_tangent_vector(self, p_ind, vector): 27 | if len(vector) != 2: 28 | raise ValueError("vector should be a 2D tangent vector") 29 | return self.bound_solver.transport_tangent_vector(p_ind, vector) 30 | 31 | def transport_tangent_vectors(self, p_inds, vectors): 32 | if len(p_inds) != len(vectors): 33 | raise ValueError("source point indices and values array should be same length") 34 | return self.bound_solver.transport_tangent_vectors(p_inds, vectors) 35 | 36 | def compute_log_map(self, p_ind): 37 | return self.bound_solver.compute_log_map(p_ind) 38 | 39 | 40 | class PointCloudLocalTriangulation(): 41 | 42 | def __init__(self, P, with_degeneracy_heuristic=True): 43 | validate_points(P) 44 | self.bound_triangulation = pp3db.PointCloudLocalTriangulation(P, with_degeneracy_heuristic) 45 | 46 | def get_local_triangulation(self): 47 | """Return the local point cloud triangulation 48 | 49 | The out matrix has the following convention: 50 | size: num_points, max_neighs, 3. max_neighs is the maximum number of neighbors 51 | out[point_idx, neigh_idx, :] are the indices of the 3 neighbors 52 | -1 is used as the fill value for unused elements if num_neighs < max_neighs for a point 53 | """ 54 | out = self.bound_triangulation.get_local_triangulation() 55 | assert out.shape[-1] % 3 == 0 56 | max_neighs = out.shape[-1] // 3 57 | out = np.reshape(out, [-1, max_neighs, 3]) 58 | return out 59 | -------------------------------------------------------------------------------- /test/bunny_small.ply: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nmwsharp/potpourri3d/29e7aeac77b10c0301418100c72919b3936c9850/test/bunny_small.ply -------------------------------------------------------------------------------- /test/potpourri3d_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | import os.path as path 5 | import numpy as np 6 | import scipy 7 | 8 | # Path to where the bindings live 9 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) 10 | if os.name == 'nt': # if Windows 11 | # handle default location where VS puts binary 12 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Debug"))) 13 | else: 14 | # normal / unix case 15 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))) 16 | 17 | 18 | import potpourri3d as pp3d 19 | 20 | asset_path = os.path.abspath(os.path.dirname(__file__)) 21 | 22 | def generate_verts(n_pts=999): 23 | np.random.seed(777) 24 | return np.random.rand(n_pts, 3) 25 | 26 | def generate_faces(n_pts=999): 27 | # n_pts should be a multiple of 3 for indexing to work out 28 | np.random.seed(777) 29 | rand_faces = np.random.randint(0, n_pts, size=(2*n_pts,3)) 30 | coverage_faces = np.arange(n_pts).reshape(-1, 3) 31 | faces = np.vstack((rand_faces, coverage_faces)) 32 | return faces 33 | 34 | def is_symmetric(A, eps=1e-6): 35 | resid = A - A.T 36 | return np.all(np.abs(resid.data) < eps) 37 | 38 | def is_nonnegative(A, eps=1e-6): 39 | return np.all(A.data > -eps) 40 | 41 | class TestCore(unittest.TestCase): 42 | 43 | def test_write_read_mesh(self): 44 | 45 | for ext in ['obj']: 46 | 47 | V = generate_verts() 48 | F = generate_faces() 49 | 50 | fname = "test." + ext 51 | 52 | # write 53 | pp3d.write_mesh(V,F,fname) 54 | 55 | Vnew, Fnew = pp3d.read_mesh(fname) 56 | 57 | self.assertLess(np.amax(np.abs(V-Vnew)), 1e-6) 58 | self.assertTrue((F==Fnew).all()) 59 | 60 | 61 | # smoke test various UV writers 62 | UV_vert = V[:,:2] 63 | pp3d.write_mesh(V,F,fname,UV_coords=UV_vert, UV_type='per-vertex') 64 | 65 | UV_face = F[:,:2] * .3 66 | pp3d.write_mesh(V,F,fname,UV_coords=UV_face, UV_type='per-face') 67 | 68 | UV_corner = np.zeros((F.shape[0]*F.shape[1],2)) 69 | pp3d.write_mesh(V,F,fname,UV_coords=UV_corner, UV_type='per-corner') 70 | 71 | def test_write_read_point_cloud(self): 72 | 73 | for ext in ['obj', 'ply']: 74 | 75 | V = generate_verts() 76 | 77 | fname = "test_cloud." + ext 78 | 79 | # write 80 | pp3d.write_point_cloud(V, fname) 81 | 82 | Vnew = pp3d.read_point_cloud(fname) 83 | 84 | self.assertLess(np.amax(np.abs(V-Vnew)), 1e-6) 85 | 86 | # self.assertTrue(is_nonnegative(off_L)) # positive edge weights 87 | # self.assertGreater(L.sum(), -1e-5) 88 | # self.assertEqual(M.sum(), M.diagonal().sum()) 89 | 90 | 91 | def test_mesh_heat_distance(self): 92 | 93 | V = generate_verts() 94 | F = generate_faces() 95 | 96 | # Test stateful version 97 | 98 | solver = pp3d.MeshHeatMethodDistanceSolver(V,F) 99 | dist = solver.compute_distance(7) 100 | self.assertEqual(dist.shape[0], V.shape[0]) 101 | 102 | dist = solver.compute_distance_multisource([1,2,3]) 103 | self.assertEqual(dist.shape[0], V.shape[0]) 104 | 105 | 106 | # = Test one-off versions 107 | 108 | dist = pp3d.compute_distance(V,F,7) 109 | self.assertEqual(dist.shape[0], V.shape[0]) 110 | 111 | dist = pp3d.compute_distance_multisource(V,F,[1,3,4]) 112 | self.assertEqual(dist.shape[0], V.shape[0]) 113 | 114 | 115 | def test_mesh_vector_heat(self): 116 | 117 | V, F = pp3d.read_mesh(os.path.join(asset_path, "bunny_small.ply")) 118 | 119 | solver = pp3d.MeshVectorHeatSolver(V,F) 120 | 121 | # Scalar extension 122 | ext = solver.extend_scalar([1, 22], [0., 6.]) 123 | self.assertEqual(ext.shape[0], V.shape[0]) 124 | self.assertGreaterEqual(np.amin(ext), 0.) 125 | 126 | # Get frames 127 | basisX, basisY, basisN = solver.get_tangent_frames() 128 | self.assertEqual(basisX.shape[0], V.shape[0]) 129 | self.assertEqual(basisY.shape[0], V.shape[0]) 130 | self.assertEqual(basisN.shape[0], V.shape[0]) 131 | # TODO could check orthogonal 132 | 133 | # Get connection Laplacian 134 | L_conn = solver.get_connection_laplacian() 135 | self.assertTrue(isinstance(L_conn, scipy.sparse.csc_matrix)) 136 | max_diag_imag = np.max(np.abs(L_conn.diagonal().imag)) 137 | self.assertLess(max_diag_imag, 1e-4) 138 | 139 | # Vector heat (transport vector) 140 | ext = solver.transport_tangent_vector(1, [6., 6.]) 141 | self.assertEqual(ext.shape[0], V.shape[0]) 142 | self.assertEqual(ext.shape[1], 2) 143 | ext = solver.transport_tangent_vectors([1, 22], [[6., 6.], [3., 4.]]) 144 | self.assertEqual(ext.shape[0], V.shape[0]) 145 | self.assertEqual(ext.shape[1], 2) 146 | 147 | # Vector heat (log map) 148 | logmap = solver.compute_log_map(1) 149 | self.assertEqual(logmap.shape[0], V.shape[0]) 150 | self.assertEqual(logmap.shape[1], 2) 151 | 152 | def test_mesh_cotan_laplace(self): 153 | 154 | V, F = pp3d.read_mesh(os.path.join(asset_path, "bunny_small.ply")) 155 | 156 | L = pp3d.cotan_laplacian(V,F) 157 | 158 | self.assertEqual(L.shape[0],V.shape[0]) 159 | self.assertEqual(L.shape[1],V.shape[0]) 160 | 161 | self.assertLess(np.abs(np.sum(L)), 1e-6) 162 | 163 | def test_mesh_areas(self): 164 | 165 | V, F = pp3d.read_mesh(os.path.join(asset_path, "bunny_small.ply")) 166 | 167 | face_area = pp3d.face_areas(V,F) 168 | self.assertEqual(face_area.shape[0],F.shape[0]) 169 | self.assertTrue(np.all(face_area >= 0)) 170 | 171 | vert_area = pp3d.vertex_areas(V,F) 172 | self.assertLess(np.abs(np.sum(face_area) - np.sum(vert_area)), 1e-6) 173 | 174 | def test_mesh_flip_geodesic(self): 175 | 176 | V, F = pp3d.read_mesh(os.path.join(asset_path, "bunny_small.ply")) 177 | 178 | # Test stateful version 179 | path_solver = pp3d.EdgeFlipGeodesicSolver(V,F) 180 | 181 | # Do a first path 182 | path_pts = path_solver.find_geodesic_path(v_start=14, v_end=22) 183 | self.assertEqual(len(path_pts.shape), 2) 184 | self.assertEqual(path_pts.shape[1], 3) 185 | path_pts = path_solver.find_geodesic_path(v_start=14, v_end=22, max_iterations=100, max_relative_length_decrease=0.5) 186 | 187 | # Do some more 188 | for i in range(5): 189 | path_pts = path_solver.find_geodesic_path(v_start=14, v_end=22+i) 190 | self.assertEqual(len(path_pts.shape), 2) 191 | self.assertEqual(path_pts.shape[1], 3) 192 | 193 | # Initialize with a compound path 194 | path_pts = path_solver.find_geodesic_path_poly([1173, 148, 870, 898]) 195 | self.assertEqual(len(path_pts.shape), 2) 196 | self.assertEqual(path_pts.shape[1], 3) 197 | path_pts = path_solver.find_geodesic_path_poly([1173, 148, 870, 898], max_iterations=100, max_relative_length_decrease=0.5) 198 | 199 | # Do a loop 200 | loop_pts = path_solver.find_geodesic_loop([1173, 148, 870, 898]) 201 | self.assertEqual(len(loop_pts.shape), 2) 202 | self.assertEqual(loop_pts.shape[1], 3) 203 | 204 | # Do another loop 205 | # this one contracts to a point 206 | loop_pts = path_solver.find_geodesic_loop([307, 757, 190]) 207 | self.assertEqual(len(loop_pts.shape), 2) 208 | self.assertEqual(loop_pts.shape[1], 3) 209 | loop_pts = path_solver.find_geodesic_loop([307, 757, 190], max_iterations=100, max_relative_length_decrease=0.5) 210 | 211 | 212 | def test_geodesic_trace(self): 213 | 214 | V, F = pp3d.read_mesh(os.path.join(asset_path, "bunny_small.ply")) 215 | 216 | # Test stateful version 217 | tracer = pp3d.GeodesicTracer(V,F) 218 | 219 | # Trace from a vertex 220 | trace_pts = tracer.trace_geodesic_from_vertex(22, np.array((0.3, 0.5, 0.4))) 221 | self.assertEqual(len(trace_pts.shape), 2) 222 | self.assertEqual(trace_pts.shape[1], 3) 223 | 224 | trace_pts = tracer.trace_geodesic_from_vertex(22, np.array((0.3, 0.5, 0.4)), max_iterations=10) 225 | self.assertEqual(len(trace_pts.shape), 2) 226 | self.assertEqual(trace_pts.shape[1], 3) 227 | 228 | # Trace from a face 229 | trace_pts = tracer.trace_geodesic_from_face(31, np.array((0.1, 0.4, 0.5)), np.array((0.3, 0.5, 0.4))) 230 | self.assertEqual(len(trace_pts.shape), 2) 231 | self.assertEqual(trace_pts.shape[1], 3) 232 | 233 | trace_pts = tracer.trace_geodesic_from_face(31, np.array((0.1, 0.4, 0.5)), np.array((0.3, 0.5, 0.4)), max_iterations=10) 234 | self.assertEqual(len(trace_pts.shape), 2) 235 | self.assertEqual(trace_pts.shape[1], 3) 236 | 237 | def test_point_cloud_distance(self): 238 | 239 | P = generate_verts() 240 | 241 | solver = pp3d.PointCloudHeatSolver(P) 242 | 243 | dist = solver.compute_distance(7) 244 | self.assertEqual(dist.shape[0], P.shape[0]) 245 | 246 | dist = solver.compute_distance_multisource([1,2,3]) 247 | self.assertEqual(dist.shape[0], P.shape[0]) 248 | 249 | def test_point_cloud_vector_heat(self): 250 | 251 | P = generate_verts() 252 | 253 | solver = pp3d.PointCloudHeatSolver(P) 254 | 255 | # Scalar extension 256 | ext = solver.extend_scalar([1, 22], [0., 6.]) 257 | self.assertEqual(ext.shape[0], P.shape[0]) 258 | self.assertGreaterEqual(np.amin(ext), 0.) 259 | 260 | # Get frames 261 | basisX, basisY, basisN = solver.get_tangent_frames() 262 | self.assertEqual(basisX.shape[0], P.shape[0]) 263 | self.assertEqual(basisY.shape[0], P.shape[0]) 264 | self.assertEqual(basisN.shape[0], P.shape[0]) 265 | # TODO could check orthogonal 266 | 267 | # Vector heat (transport vector) 268 | ext = solver.transport_tangent_vector(1, [6., 6.]) 269 | self.assertEqual(ext.shape[0], P.shape[0]) 270 | self.assertEqual(ext.shape[1], 2) 271 | ext = solver.transport_tangent_vectors([1, 22], [[6., 6.], [3., 4.]]) 272 | self.assertEqual(ext.shape[0], P.shape[0]) 273 | self.assertEqual(ext.shape[1], 2) 274 | 275 | # Vector heat (log map) 276 | logmap = solver.compute_log_map(1) 277 | self.assertEqual(logmap.shape[0], P.shape[0]) 278 | self.assertEqual(logmap.shape[1], 2) 279 | 280 | def test_point_cloud_local_triangulation(self): 281 | # Test local triangulation for a "cartwheel" pointcloud 282 | 283 | num = 31 284 | t = np.linspace(0, 2*np.pi, num-1, endpoint=False) 285 | points = np.concatenate([np.zeros([1, 3]), np.stack([np.cos(t), np.sin(t), 0*t], 1)], 0) 286 | 287 | pcl_local_tri = pp3d.PointCloudLocalTriangulation(points) 288 | idxs = pcl_local_tri.get_local_triangulation() 289 | 290 | def next_id(i): 291 | assert i != 0 292 | if i == num-1: 293 | return 1 294 | return i+1 295 | 296 | def prev_id(i): 297 | assert i != 0 298 | if i == 1: 299 | return num-1 300 | return i-1 301 | 302 | 303 | # Explicitly check for cartwheel 304 | # Use sets for order invariance 305 | 306 | res0 = set(tuple(r) for r in idxs[0]) 307 | ref0 = set((0, j, next_id(j)) for j in range(1, num)) 308 | self.assertEqual(res0, ref0) 309 | 310 | for i in range(1, num): 311 | self.assertTrue(np.all(idxs[i, 2:] == -1)) 312 | 313 | res = set(tuple(r) for r in idxs[i, :2]) 314 | ref = {(i, next_id(i), 0), (i, 0, prev_id(i))} 315 | self.assertEqual(res, ref) 316 | 317 | 318 | if __name__ == '__main__': 319 | unittest.main() 320 | -------------------------------------------------------------------------------- /test/sample.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | 3 | import polyscope as ps 4 | import numpy as np 5 | import scipy 6 | import scipy.sparse 7 | import scipy.sparse.linalg 8 | 9 | # Path to where the bindings live 10 | sys.path.append(os.path.join(os.path.dirname(__file__), "../build/")) 11 | sys.path.append(os.path.join(os.path.dirname(__file__), "../src/")) 12 | 13 | import potpourri3d as pp3d 14 | 15 | ps.init() 16 | 17 | # Read input 18 | 19 | ## = Mesh test 20 | V, F = pp3d.read_mesh("bunny_small.ply") 21 | ps_mesh = ps.register_surface_mesh("mesh", V, F) 22 | 23 | # Distance 24 | dists = pp3d.compute_distance(V, F, 4) 25 | ps_mesh.add_scalar_quantity("dist", dists) 26 | 27 | # Vector heat 28 | solver = pp3d.MeshVectorHeatSolver(V, F) 29 | 30 | # Vector heat (extend scalar) 31 | ext = solver.extend_scalar([1, 22], [0., 6.]) 32 | ps_mesh.add_scalar_quantity("ext", ext) 33 | 34 | # Vector heat (tangent frames) 35 | basisX, basisY, basisN = solver.get_tangent_frames() 36 | ps_mesh.add_vector_quantity("basisX", basisX) 37 | ps_mesh.add_vector_quantity("basisY", basisY) 38 | ps_mesh.add_vector_quantity("basisN", basisN) 39 | 40 | # Vector heat (transport vector) 41 | ext = solver.transport_tangent_vector(1, [6., 6.]) 42 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 43 | ps_mesh.add_vector_quantity("transport vec", ext3D) 44 | 45 | ext = solver.transport_tangent_vectors([1, 22], [[6., 6.], [3., 4.]]) 46 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 47 | ps_mesh.add_vector_quantity("transport vec2", ext3D) 48 | 49 | # Vector heat (log map) 50 | logmap = solver.compute_log_map(1) 51 | ps_mesh.add_parameterization_quantity("logmap", logmap) 52 | 53 | # Flip geodesics 54 | path_solver = pp3d.EdgeFlipGeodesicSolver(V,F) 55 | for k in range(50): 56 | for i in range(5): 57 | path_pts = path_solver.find_geodesic_path(v_start=1, v_end=22+i) 58 | ps.register_curve_network("flip path " + str(i), path_pts, edges='line') 59 | 60 | path_pts = path_solver.find_geodesic_path_poly([1173, 148, 870, 898]) 61 | ps.register_curve_network("flip path poly", path_pts, edges='line') 62 | 63 | loop_pts = path_solver.find_geodesic_loop([1173, 148, 870, 898]) 64 | ps.register_curve_network("flip loop", loop_pts, edges='loop') 65 | 66 | loop_pts = path_solver.find_geodesic_loop([307, 757, 190], max_relative_length_decrease=.9) # this one otherwise contracts to a point 67 | ps.register_curve_network("flip loop", loop_pts, edges='loop') 68 | 69 | 70 | # Trace geodesics 71 | tracer = pp3d.GeodesicTracer(V,F) 72 | 73 | trace_pts = tracer.trace_geodesic_from_vertex(22, np.array((0.3, 0.5, 0.4))) 74 | ps.register_curve_network("trace vertex geodesic", trace_pts, edges='line') 75 | 76 | trace_pts = tracer.trace_geodesic_from_face(31, np.array((0.1, 0.4, 0.5)), np.array((0.3, 0.5, 0.4))) 77 | ps.register_curve_network("trace face geodesic", trace_pts, edges='line') 78 | 79 | ## = Point cloud test 80 | P = V 81 | ps_cloud = ps.register_point_cloud("cloud", P) 82 | 83 | # == heat solver 84 | solver = pp3d.PointCloudHeatSolver(P) 85 | 86 | # distance 87 | dists = solver.compute_distance(4) 88 | dists2 = solver.compute_distance_multisource([4, 13, 784]) 89 | ps_cloud.add_scalar_quantity("dist", dists) 90 | ps_cloud.add_scalar_quantity("dist2", dists2) 91 | 92 | # scalar extension 93 | ext = solver.extend_scalar([1, 22], [0., 6.]) 94 | ps_cloud.add_scalar_quantity("ext", ext) 95 | 96 | # Vector heat (tangent frames) 97 | basisX, basisY, basisN = solver.get_tangent_frames() 98 | ps_cloud.add_vector_quantity("basisX", basisX) 99 | ps_cloud.add_vector_quantity("basisY", basisY) 100 | ps_cloud.add_vector_quantity("basisN", basisN) 101 | 102 | # Vector heat (transport vector) 103 | ext = solver.transport_tangent_vector(1, [6., 6.]) 104 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 105 | ps_cloud.add_vector_quantity("transport vec", ext3D) 106 | 107 | ext = solver.transport_tangent_vectors([1, 22], [[6., 6.], [3., 4.]]) 108 | ext3D = ext[:,0,np.newaxis] * basisX + ext[:,1,np.newaxis] * basisY 109 | ps_cloud.add_vector_quantity("transport vec2", ext3D) 110 | 111 | # Vector heat (log map) 112 | logmap = solver.compute_log_map(1) 113 | ps_cloud.add_scalar_quantity("logmapX", logmap[:,0]) 114 | ps_cloud.add_scalar_quantity("logmapY", logmap[:,1]) 115 | 116 | # Areas 117 | vert_area = pp3d.vertex_areas(V,F) 118 | ps_mesh.add_scalar_quantity("vert area", vert_area) 119 | face_area = pp3d.face_areas(V,F) 120 | ps_mesh.add_scalar_quantity("face area", face_area, defined_on='faces') 121 | 122 | 123 | # Laplacian 124 | L = pp3d.cotan_laplacian(V,F,denom_eps=1e-6) 125 | M = scipy.sparse.diags(vert_area) 126 | k_eig = 6 127 | evals, evecs = scipy.sparse.linalg.eigsh(L, k_eig, M, sigma=1e-8) 128 | for i in range(k_eig): 129 | ps_mesh.add_scalar_quantity("evec " + str(i), evecs[:,i]) 130 | 131 | ps.show() 132 | --------------------------------------------------------------------------------