├── .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 | 
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 |
--------------------------------------------------------------------------------