Skip to content

Commit 8c60783

Browse files
authored
Merge pull request #58 from jf---/jf/geodesics-module
add heat method geodesics module
2 parents 2c07439 + ec965eb commit 8c60783

File tree

10 files changed

+876
-1
lines changed

10 files changed

+876
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Unreleased
99

1010
### Added
11-
* Added `compas_cgal.skeletonization.mesh_skeleton_with_mapping`.
11+
* Added `compas_cgal.skeletonization.mesh_skeleton_with_mapping`
12+
- `HeatGeodesicSolver` class with precomputation for repeated queries
13+
- `heat_geodesic_distances` function for single-shot usage
14+
- uses CGAL Heat_method_3 with intrinsic Delaunay triangulation
15+
- ~30% faster than `libigl` heat in `compas_slicer` workflow
1216

1317
- Added `simplify_polyline` and `simplify_polylines` functions for polyline simplification using Douglas-Peucker algorithm
1418
- Added `closest_points_on_polyline` function for batch closest point queries on polylines

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,4 @@ add_nanobind_module(_triangulation src/triangulation.cpp)
238238
add_nanobind_module(_subdivision src/subdivision.cpp)
239239
add_nanobind_module(_polylines src/polylines.cpp)
240240

241+
add_nanobind_module(_geodesics src/geodesics.cpp)

docs/_images/example_geodesics.png

286 KB
Loading

docs/examples/example_geodesics.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
from compas.colors import Color, ColorMap
5+
from compas.datastructures import Mesh
6+
from compas.geometry import Box, Point, Polyline, Translation
7+
from compas_viewer import Viewer
8+
from compas_viewer.config import Config
9+
10+
from compas_cgal.geodesics import (
11+
HeatGeodesicSolver,
12+
geodesic_isolines,
13+
geodesic_isolines_split,
14+
heat_geodesic_distances,
15+
)
16+
17+
# Load mesh
18+
FILE = Path(__file__).parent.parent.parent / "data" / "elephant.off"
19+
mesh = Mesh.from_off(FILE)
20+
mesh.quads_to_triangles()
21+
V, F = mesh.to_vertices_and_faces()
22+
V_np = np.array(V)
23+
24+
# Config
25+
X_OFF, Y_OFF = 0.75, 1.0
26+
ISOVALUES = [i/50 for i in range(50)]
27+
SPLIT_ISOVALUES = [i/20 for i in range(20)]
28+
COLORS = [Color.red(), Color.orange(), Color.yellow(), Color.green(),
29+
Color.cyan(), Color.blue(), Color.purple(), Color.magenta()]
30+
cmap = ColorMap.from_two_colors(Color.blue(), Color.red())
31+
32+
33+
def make_mesh(V, F, offset):
34+
m = Mesh.from_vertices_and_faces(V, F)
35+
m.transform(Translation.from_vector([offset[0], offset[1], 0]))
36+
return m
37+
38+
39+
def make_vertex_colors(distances):
40+
return {i: cmap(d, minval=distances.min(), maxval=distances.max()) for i, d in enumerate(distances)}
41+
42+
43+
def make_isolines(sources, offset):
44+
polylines = []
45+
for pts in geodesic_isolines((V, F), sources, ISOVALUES):
46+
points = [[pts[i, 0] + offset[0], pts[i, 1] + offset[1], pts[i, 2]] for i in range(len(pts))]
47+
polylines.append(Polyline(points))
48+
return polylines
49+
50+
51+
def make_split_meshes(sources, offset):
52+
meshes = []
53+
for i, (v, f) in enumerate(geodesic_isolines_split((V, F), sources, SPLIT_ISOVALUES)):
54+
m = Mesh.from_vertices_and_faces(v.tolist(), f.tolist())
55+
m.transform(Translation.from_vector([offset[0], offset[1], 0]))
56+
meshes.append((m, COLORS[i % len(COLORS)]))
57+
return meshes
58+
59+
60+
def make_source_points(sources, offset):
61+
return [Point(V[s][0] + offset[0], V[s][1] + offset[1], V[s][2]) for s in sources]
62+
63+
64+
# Row 1: Single source
65+
src1 = [0]
66+
dist1 = heat_geodesic_distances((V, F), src1)
67+
68+
# Row 2: Multiple sources (bbox corners)
69+
solver = HeatGeodesicSolver((V, F))
70+
bbox = Box.from_points(V_np)
71+
src2 = list(dict.fromkeys([int(np.argmin(np.linalg.norm(V_np - c, axis=1))) for c in bbox.vertices]))
72+
dist2 = solver.solve(src2)
73+
74+
# Viewer
75+
config = Config()
76+
config.camera.target = [X_OFF, -Y_OFF / 2, 0]
77+
config.camera.position = [X_OFF, -2.0, 0.8]
78+
viewer = Viewer(config=config)
79+
80+
# Row 1: Single Source
81+
g1 = viewer.scene.add_group("Single Source")
82+
g1.add(make_mesh(V, F, (0, 0)), use_vertexcolors=True, vertexcolor=make_vertex_colors(dist1), show_lines=False)
83+
for pt in make_source_points(src1, (0, 0)):
84+
g1.add(pt, pointcolor=Color.black(), pointsize=20)
85+
# g1.add(make_mesh(V, F, (X_OFF, 0)), facecolor=Color.grey(), show_lines=True)
86+
for pl in make_isolines(src1, (X_OFF, 0)):
87+
g1.add(pl, linecolor=Color.red(), lineswidth=5)
88+
for m, c in make_split_meshes(src1, (2 * X_OFF, 0)):
89+
g1.add(m, facecolor=c, show_lines=False)
90+
91+
# Row 2: Multiple Sources
92+
g2 = viewer.scene.add_group("Multiple Sources")
93+
g2.add(make_mesh(V, F, (0, -Y_OFF)), use_vertexcolors=True, vertexcolor=make_vertex_colors(dist2), show_lines=False)
94+
for pt in make_source_points(src2, (0, -Y_OFF)):
95+
g2.add(pt, pointcolor=Color.black(), pointsize=20)
96+
# g2.add(make_mesh(V, F, (X_OFF, -Y_OFF)), facecolor=Color.grey(), show_lines=True)
97+
for pl in make_isolines(src2, (X_OFF, -Y_OFF)):
98+
g2.add(pl, linecolor=Color.red(), lineswidth=5)
99+
for m, c in make_split_meshes(src2, (2 * X_OFF, -Y_OFF)):
100+
g2.add(m, facecolor=c, show_lines=False)
101+
102+
viewer.show()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Geodesic Distances and Isolines
2+
===============================
3+
4+
This example demonstrates geodesic distance computation, isoline extraction, and mesh splitting using COMPAS CGAL.
5+
6+
The visualization shows two rows:
7+
8+
* **Row 1 (Single Source)**: Geodesic distances from a single vertex
9+
* **Row 2 (Multiple Sources)**: Geodesic distances from all bounding box corners
10+
11+
Each row displays three columns:
12+
13+
* **Heat Field**: Mesh colored by geodesic distance (blue → red gradient) with source points marked in black
14+
* **Isolines**: Extracted isoline polylines at regular distance intervals
15+
* **Split Mesh**: Mesh split into components along the isolines, each colored differently
16+
17+
Key Features:
18+
19+
* Heat method geodesic distance computation via ``heat_geodesic_distances``
20+
* Reusable solver via ``HeatGeodesicSolver`` for multiple queries
21+
* Isoline extraction as polylines via ``geodesic_isolines``
22+
* Mesh splitting along isolines via ``geodesic_isolines_split``
23+
24+
.. figure:: /_images/example_geodesics.png
25+
:figclass: figure
26+
:class: figure-img img-fluid
27+
28+
.. literalinclude:: example_geodesics.py
29+
:language: python

src/compas_cgal/geodesics.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Geodesic distance computation using CGAL heat method."""
2+
3+
from typing import List
4+
5+
import numpy as np
6+
from numpy.typing import NDArray
7+
8+
from compas_cgal import _types_std # noqa: F401 # Load vector type bindings
9+
from compas_cgal._geodesics import geodesic_isolines as _geodesic_isolines
10+
from compas_cgal._geodesics import geodesic_isolines_split as _geodesic_isolines_split
11+
from compas_cgal._geodesics import heat_geodesic_distances as _heat_geodesic_distances
12+
from compas_cgal._geodesics import HeatGeodesicSolver as _HeatGeodesicSolver
13+
from compas_cgal.types import PolylinesNumpy
14+
from compas_cgal.types import VerticesFaces
15+
from compas_cgal.types import VerticesFacesNumpy
16+
17+
__all__ = ["heat_geodesic_distances", "HeatGeodesicSolver", "geodesic_isolines_split", "geodesic_isolines"]
18+
19+
20+
def heat_geodesic_distances(mesh: VerticesFaces, sources: List[int]) -> NDArray:
21+
"""Compute geodesic distances from source vertices using CGAL heat method.
22+
23+
Uses CGAL's Heat_method_3 with intrinsic Delaunay triangulation for
24+
accurate geodesic distance computation.
25+
26+
Parameters
27+
----------
28+
mesh : :attr:`compas_cgal.types.VerticesFaces`
29+
A triangulated mesh as a tuple of vertices and faces.
30+
sources : List[int]
31+
Source vertex indices.
32+
33+
Returns
34+
-------
35+
NDArray
36+
Geodesic distances from the nearest source to each vertex.
37+
Shape is (n_vertices,).
38+
39+
Examples
40+
--------
41+
>>> from compas.geometry import Box
42+
>>> from compas_cgal.geodesics import heat_geodesic_distances
43+
>>> box = Box(1)
44+
>>> mesh = box.to_vertices_and_faces(triangulated=True)
45+
>>> distances = heat_geodesic_distances(mesh, [0]) # distances from vertex 0
46+
47+
"""
48+
V, F = mesh
49+
V = np.asarray(V, dtype=np.float64, order="C")
50+
F = np.asarray(F, dtype=np.int32, order="C")
51+
52+
result = _heat_geodesic_distances(V, F, sources)
53+
return result.flatten()
54+
55+
56+
class HeatGeodesicSolver:
57+
"""Precomputed heat method solver for repeated geodesic queries.
58+
59+
Use this class when computing geodesic distances from multiple
60+
different sources on the same mesh. The expensive precomputation
61+
is done once in the constructor, and solve() can be called many
62+
times efficiently.
63+
64+
Parameters
65+
----------
66+
mesh : :attr:`compas_cgal.types.VerticesFaces`
67+
A triangulated mesh as a tuple of vertices and faces.
68+
69+
Examples
70+
--------
71+
>>> from compas.geometry import Sphere
72+
>>> from compas_cgal.geodesics import HeatGeodesicSolver
73+
>>> sphere = Sphere(1.0)
74+
>>> mesh = sphere.to_vertices_and_faces(u=32, v=32, triangulated=True)
75+
>>> solver = HeatGeodesicSolver(mesh) # precomputation happens here
76+
>>> d0 = solver.solve([0]) # distances from vertex 0
77+
>>> d1 = solver.solve([1]) # distances from vertex 1 (fast, reuses precomputation)
78+
79+
"""
80+
81+
def __init__(self, mesh: VerticesFaces) -> None:
82+
V, F = mesh
83+
V = np.asarray(V, dtype=np.float64, order="C")
84+
F = np.asarray(F, dtype=np.int32, order="C")
85+
self._solver = _HeatGeodesicSolver(V, F)
86+
87+
def solve(self, sources: List[int]) -> NDArray:
88+
"""Compute geodesic distances from source vertices.
89+
90+
Parameters
91+
----------
92+
sources : List[int]
93+
Source vertex indices.
94+
95+
Returns
96+
-------
97+
NDArray
98+
Geodesic distances from the nearest source to each vertex.
99+
Shape is (n_vertices,).
100+
101+
"""
102+
result = self._solver.solve(sources)
103+
return result.flatten()
104+
105+
@property
106+
def num_vertices(self) -> int:
107+
"""Number of vertices in the mesh."""
108+
return self._solver.num_vertices
109+
110+
111+
def geodesic_isolines_split(
112+
mesh: VerticesFaces,
113+
sources: List[int],
114+
isovalues: List[float],
115+
) -> List[VerticesFacesNumpy]:
116+
"""Split mesh into components along geodesic isolines.
117+
118+
Computes geodesic distances from sources, refines the mesh along
119+
specified isovalue thresholds, and splits into connected components.
120+
121+
Parameters
122+
----------
123+
mesh : :attr:`compas_cgal.types.VerticesFaces`
124+
A triangulated mesh as a tuple of vertices and faces.
125+
sources : List[int]
126+
Source vertex indices for geodesic distance computation.
127+
isovalues : List[float]
128+
Isovalue thresholds for splitting. The mesh will be refined
129+
along curves where the geodesic distance equals each isovalue,
130+
then split into connected components.
131+
132+
Returns
133+
-------
134+
List[:attr:`compas_cgal.types.VerticesFacesNumpy`]
135+
List of mesh components as (vertices, faces) tuples.
136+
137+
Examples
138+
--------
139+
>>> from compas.geometry import Sphere
140+
>>> from compas_cgal.geodesics import geodesic_isolines_split
141+
>>> sphere = Sphere(1.0)
142+
>>> mesh = sphere.to_vertices_and_faces(u=32, v=32, triangulated=True)
143+
>>> components = geodesic_isolines_split(mesh, [0], [0.5, 1.0, 1.5])
144+
>>> len(components) # Number of mesh strips
145+
146+
"""
147+
V, F = mesh
148+
V = np.asarray(V, dtype=np.float64, order="C")
149+
F = np.asarray(F, dtype=np.int32, order="C")
150+
151+
vertices_list, faces_list = _geodesic_isolines_split(V, F, sources, isovalues)
152+
return list(zip(vertices_list, faces_list))
153+
154+
155+
def geodesic_isolines(
156+
mesh: VerticesFaces,
157+
sources: List[int],
158+
isovalues: List[float],
159+
) -> PolylinesNumpy:
160+
"""Extract isoline polylines from geodesic distance field.
161+
162+
Computes geodesic distances and extracts polylines along specified isovalues.
163+
164+
Parameters
165+
----------
166+
mesh : :attr:`compas_cgal.types.VerticesFaces`
167+
A triangulated mesh as a tuple of vertices and faces.
168+
sources : List[int]
169+
Source vertex indices for geodesic distance computation.
170+
isovalues : List[float]
171+
Isovalue thresholds for isoline extraction.
172+
173+
Returns
174+
-------
175+
:attr:`compas_cgal.types.PolylinesNumpy`
176+
List of polyline segments as Nx3 arrays of points.
177+
178+
"""
179+
V, F = mesh
180+
V = np.asarray(V, dtype=np.float64, order="C")
181+
F = np.asarray(F, dtype=np.int32, order="C")
182+
183+
return list(_geodesic_isolines(V, F, sources, isovalues))

0 commit comments

Comments
 (0)