-
Notifications
You must be signed in to change notification settings - Fork 9
add heat method geodesics module #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+876
−1
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1f8a5f8
add heat method geodesics module
jf--- af1166f
changelog: add geodesics module entries per reviewer request
jf--- c289ef7
refactor geodesics: use VerticesFaces type, py39 compat, add tests
jf--- e9eb071
Add geodesic isolines and mesh splitting functionality
877320e
Clean up test_geodesics.py by removing docstring
jf--- ec965eb
Merge branch 'compas-dev:main' into jf/geodesics-module
jf--- File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| from pathlib import Path | ||
|
|
||
| import numpy as np | ||
| from compas.colors import Color, ColorMap | ||
| from compas.datastructures import Mesh | ||
| from compas.geometry import Box, Point, Polyline, Translation | ||
| from compas_viewer import Viewer | ||
| from compas_viewer.config import Config | ||
|
|
||
| from compas_cgal.geodesics import ( | ||
| HeatGeodesicSolver, | ||
| geodesic_isolines, | ||
| geodesic_isolines_split, | ||
| heat_geodesic_distances, | ||
| ) | ||
|
|
||
| # Load mesh | ||
| FILE = Path(__file__).parent.parent.parent / "data" / "elephant.off" | ||
| mesh = Mesh.from_off(FILE) | ||
| mesh.quads_to_triangles() | ||
| V, F = mesh.to_vertices_and_faces() | ||
| V_np = np.array(V) | ||
|
|
||
| # Config | ||
| X_OFF, Y_OFF = 0.75, 1.0 | ||
| ISOVALUES = [i/50 for i in range(50)] | ||
| SPLIT_ISOVALUES = [i/20 for i in range(20)] | ||
| COLORS = [Color.red(), Color.orange(), Color.yellow(), Color.green(), | ||
| Color.cyan(), Color.blue(), Color.purple(), Color.magenta()] | ||
| cmap = ColorMap.from_two_colors(Color.blue(), Color.red()) | ||
|
|
||
|
|
||
| def make_mesh(V, F, offset): | ||
| m = Mesh.from_vertices_and_faces(V, F) | ||
| m.transform(Translation.from_vector([offset[0], offset[1], 0])) | ||
| return m | ||
|
|
||
|
|
||
| def make_vertex_colors(distances): | ||
| return {i: cmap(d, minval=distances.min(), maxval=distances.max()) for i, d in enumerate(distances)} | ||
|
|
||
|
|
||
| def make_isolines(sources, offset): | ||
| polylines = [] | ||
| for pts in geodesic_isolines((V, F), sources, ISOVALUES): | ||
| points = [[pts[i, 0] + offset[0], pts[i, 1] + offset[1], pts[i, 2]] for i in range(len(pts))] | ||
| polylines.append(Polyline(points)) | ||
| return polylines | ||
|
|
||
|
|
||
| def make_split_meshes(sources, offset): | ||
| meshes = [] | ||
| for i, (v, f) in enumerate(geodesic_isolines_split((V, F), sources, SPLIT_ISOVALUES)): | ||
| m = Mesh.from_vertices_and_faces(v.tolist(), f.tolist()) | ||
| m.transform(Translation.from_vector([offset[0], offset[1], 0])) | ||
| meshes.append((m, COLORS[i % len(COLORS)])) | ||
| return meshes | ||
|
|
||
|
|
||
| def make_source_points(sources, offset): | ||
| return [Point(V[s][0] + offset[0], V[s][1] + offset[1], V[s][2]) for s in sources] | ||
|
|
||
|
|
||
| # Row 1: Single source | ||
| src1 = [0] | ||
| dist1 = heat_geodesic_distances((V, F), src1) | ||
|
|
||
| # Row 2: Multiple sources (bbox corners) | ||
| solver = HeatGeodesicSolver((V, F)) | ||
| bbox = Box.from_points(V_np) | ||
| src2 = list(dict.fromkeys([int(np.argmin(np.linalg.norm(V_np - c, axis=1))) for c in bbox.vertices])) | ||
| dist2 = solver.solve(src2) | ||
|
|
||
| # Viewer | ||
| config = Config() | ||
| config.camera.target = [X_OFF, -Y_OFF / 2, 0] | ||
| config.camera.position = [X_OFF, -2.0, 0.8] | ||
| viewer = Viewer(config=config) | ||
|
|
||
| # Row 1: Single Source | ||
| g1 = viewer.scene.add_group("Single Source") | ||
| g1.add(make_mesh(V, F, (0, 0)), use_vertexcolors=True, vertexcolor=make_vertex_colors(dist1), show_lines=False) | ||
| for pt in make_source_points(src1, (0, 0)): | ||
| g1.add(pt, pointcolor=Color.black(), pointsize=20) | ||
| # g1.add(make_mesh(V, F, (X_OFF, 0)), facecolor=Color.grey(), show_lines=True) | ||
| for pl in make_isolines(src1, (X_OFF, 0)): | ||
| g1.add(pl, linecolor=Color.red(), lineswidth=5) | ||
| for m, c in make_split_meshes(src1, (2 * X_OFF, 0)): | ||
| g1.add(m, facecolor=c, show_lines=False) | ||
|
|
||
| # Row 2: Multiple Sources | ||
| g2 = viewer.scene.add_group("Multiple Sources") | ||
| g2.add(make_mesh(V, F, (0, -Y_OFF)), use_vertexcolors=True, vertexcolor=make_vertex_colors(dist2), show_lines=False) | ||
| for pt in make_source_points(src2, (0, -Y_OFF)): | ||
| g2.add(pt, pointcolor=Color.black(), pointsize=20) | ||
| # g2.add(make_mesh(V, F, (X_OFF, -Y_OFF)), facecolor=Color.grey(), show_lines=True) | ||
| for pl in make_isolines(src2, (X_OFF, -Y_OFF)): | ||
| g2.add(pl, linecolor=Color.red(), lineswidth=5) | ||
| for m, c in make_split_meshes(src2, (2 * X_OFF, -Y_OFF)): | ||
| g2.add(m, facecolor=c, show_lines=False) | ||
|
|
||
| viewer.show() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| Geodesic Distances and Isolines | ||
| =============================== | ||
|
|
||
| This example demonstrates geodesic distance computation, isoline extraction, and mesh splitting using COMPAS CGAL. | ||
|
|
||
| The visualization shows two rows: | ||
|
|
||
| * **Row 1 (Single Source)**: Geodesic distances from a single vertex | ||
| * **Row 2 (Multiple Sources)**: Geodesic distances from all bounding box corners | ||
|
|
||
| Each row displays three columns: | ||
|
|
||
| * **Heat Field**: Mesh colored by geodesic distance (blue → red gradient) with source points marked in black | ||
| * **Isolines**: Extracted isoline polylines at regular distance intervals | ||
| * **Split Mesh**: Mesh split into components along the isolines, each colored differently | ||
|
|
||
| Key Features: | ||
|
|
||
| * Heat method geodesic distance computation via ``heat_geodesic_distances`` | ||
| * Reusable solver via ``HeatGeodesicSolver`` for multiple queries | ||
| * Isoline extraction as polylines via ``geodesic_isolines`` | ||
| * Mesh splitting along isolines via ``geodesic_isolines_split`` | ||
|
|
||
| .. figure:: /_images/example_geodesics.png | ||
| :figclass: figure | ||
| :class: figure-img img-fluid | ||
|
|
||
| .. literalinclude:: example_geodesics.py | ||
| :language: python |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| """Geodesic distance computation using CGAL heat method.""" | ||
|
|
||
| from typing import List | ||
|
|
||
| import numpy as np | ||
| from numpy.typing import NDArray | ||
|
|
||
| from compas_cgal import _types_std # noqa: F401 # Load vector type bindings | ||
| from compas_cgal._geodesics import geodesic_isolines as _geodesic_isolines | ||
| from compas_cgal._geodesics import geodesic_isolines_split as _geodesic_isolines_split | ||
| from compas_cgal._geodesics import heat_geodesic_distances as _heat_geodesic_distances | ||
| from compas_cgal._geodesics import HeatGeodesicSolver as _HeatGeodesicSolver | ||
| from compas_cgal.types import PolylinesNumpy | ||
| from compas_cgal.types import VerticesFaces | ||
| from compas_cgal.types import VerticesFacesNumpy | ||
|
|
||
| __all__ = ["heat_geodesic_distances", "HeatGeodesicSolver", "geodesic_isolines_split", "geodesic_isolines"] | ||
|
|
||
|
|
||
| def heat_geodesic_distances(mesh: VerticesFaces, sources: List[int]) -> NDArray: | ||
| """Compute geodesic distances from source vertices using CGAL heat method. | ||
|
|
||
| Uses CGAL's Heat_method_3 with intrinsic Delaunay triangulation for | ||
| accurate geodesic distance computation. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| mesh : :attr:`compas_cgal.types.VerticesFaces` | ||
| A triangulated mesh as a tuple of vertices and faces. | ||
| sources : List[int] | ||
| Source vertex indices. | ||
|
|
||
| Returns | ||
| ------- | ||
| NDArray | ||
| Geodesic distances from the nearest source to each vertex. | ||
| Shape is (n_vertices,). | ||
|
|
||
| Examples | ||
| -------- | ||
| >>> from compas.geometry import Box | ||
| >>> from compas_cgal.geodesics import heat_geodesic_distances | ||
| >>> box = Box(1) | ||
| >>> mesh = box.to_vertices_and_faces(triangulated=True) | ||
| >>> distances = heat_geodesic_distances(mesh, [0]) # distances from vertex 0 | ||
|
|
||
| """ | ||
| V, F = mesh | ||
| V = np.asarray(V, dtype=np.float64, order="C") | ||
| F = np.asarray(F, dtype=np.int32, order="C") | ||
|
|
||
| result = _heat_geodesic_distances(V, F, sources) | ||
| return result.flatten() | ||
|
|
||
|
|
||
| class HeatGeodesicSolver: | ||
| """Precomputed heat method solver for repeated geodesic queries. | ||
|
|
||
| Use this class when computing geodesic distances from multiple | ||
| different sources on the same mesh. The expensive precomputation | ||
| is done once in the constructor, and solve() can be called many | ||
| times efficiently. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| mesh : :attr:`compas_cgal.types.VerticesFaces` | ||
| A triangulated mesh as a tuple of vertices and faces. | ||
|
|
||
| Examples | ||
| -------- | ||
| >>> from compas.geometry import Sphere | ||
| >>> from compas_cgal.geodesics import HeatGeodesicSolver | ||
| >>> sphere = Sphere(1.0) | ||
| >>> mesh = sphere.to_vertices_and_faces(u=32, v=32, triangulated=True) | ||
| >>> solver = HeatGeodesicSolver(mesh) # precomputation happens here | ||
| >>> d0 = solver.solve([0]) # distances from vertex 0 | ||
| >>> d1 = solver.solve([1]) # distances from vertex 1 (fast, reuses precomputation) | ||
|
|
||
| """ | ||
|
|
||
| def __init__(self, mesh: VerticesFaces) -> None: | ||
| V, F = mesh | ||
| V = np.asarray(V, dtype=np.float64, order="C") | ||
| F = np.asarray(F, dtype=np.int32, order="C") | ||
| self._solver = _HeatGeodesicSolver(V, F) | ||
|
|
||
| def solve(self, sources: List[int]) -> NDArray: | ||
| """Compute geodesic distances from source vertices. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| sources : List[int] | ||
| Source vertex indices. | ||
|
|
||
| Returns | ||
| ------- | ||
| NDArray | ||
| Geodesic distances from the nearest source to each vertex. | ||
| Shape is (n_vertices,). | ||
|
|
||
| """ | ||
| result = self._solver.solve(sources) | ||
| return result.flatten() | ||
|
|
||
| @property | ||
| def num_vertices(self) -> int: | ||
| """Number of vertices in the mesh.""" | ||
| return self._solver.num_vertices | ||
|
|
||
|
|
||
| def geodesic_isolines_split( | ||
| mesh: VerticesFaces, | ||
| sources: List[int], | ||
| isovalues: List[float], | ||
| ) -> List[VerticesFacesNumpy]: | ||
| """Split mesh into components along geodesic isolines. | ||
|
|
||
| Computes geodesic distances from sources, refines the mesh along | ||
| specified isovalue thresholds, and splits into connected components. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| mesh : :attr:`compas_cgal.types.VerticesFaces` | ||
| A triangulated mesh as a tuple of vertices and faces. | ||
| sources : List[int] | ||
| Source vertex indices for geodesic distance computation. | ||
| isovalues : List[float] | ||
| Isovalue thresholds for splitting. The mesh will be refined | ||
| along curves where the geodesic distance equals each isovalue, | ||
| then split into connected components. | ||
|
|
||
| Returns | ||
| ------- | ||
| List[:attr:`compas_cgal.types.VerticesFacesNumpy`] | ||
| List of mesh components as (vertices, faces) tuples. | ||
|
|
||
| Examples | ||
| -------- | ||
| >>> from compas.geometry import Sphere | ||
| >>> from compas_cgal.geodesics import geodesic_isolines_split | ||
| >>> sphere = Sphere(1.0) | ||
| >>> mesh = sphere.to_vertices_and_faces(u=32, v=32, triangulated=True) | ||
| >>> components = geodesic_isolines_split(mesh, [0], [0.5, 1.0, 1.5]) | ||
| >>> len(components) # Number of mesh strips | ||
|
|
||
| """ | ||
| V, F = mesh | ||
| V = np.asarray(V, dtype=np.float64, order="C") | ||
| F = np.asarray(F, dtype=np.int32, order="C") | ||
|
|
||
| vertices_list, faces_list = _geodesic_isolines_split(V, F, sources, isovalues) | ||
| return list(zip(vertices_list, faces_list)) | ||
|
|
||
|
|
||
| def geodesic_isolines( | ||
| mesh: VerticesFaces, | ||
| sources: List[int], | ||
| isovalues: List[float], | ||
| ) -> PolylinesNumpy: | ||
| """Extract isoline polylines from geodesic distance field. | ||
|
|
||
| Computes geodesic distances and extracts polylines along specified isovalues. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| mesh : :attr:`compas_cgal.types.VerticesFaces` | ||
| A triangulated mesh as a tuple of vertices and faces. | ||
| sources : List[int] | ||
| Source vertex indices for geodesic distance computation. | ||
| isovalues : List[float] | ||
| Isovalue thresholds for isoline extraction. | ||
|
|
||
| Returns | ||
| ------- | ||
| :attr:`compas_cgal.types.PolylinesNumpy` | ||
| List of polyline segments as Nx3 arrays of points. | ||
|
|
||
| """ | ||
| V, F = mesh | ||
| V = np.asarray(V, dtype=np.float64, order="C") | ||
| F = np.asarray(F, dtype=np.int32, order="C") | ||
|
|
||
| return list(_geodesic_isolines(V, F, sources, isovalues)) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.