Skip to content

Commit e9eb071

Browse files
author
pv
committed
Add geodesic isolines and mesh splitting functionality
1 parent c289ef7 commit e9eb071

File tree

8 files changed

+516
-39
lines changed

8 files changed

+516
-39
lines changed

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: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
from numpy.typing import NDArray
77

88
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
911
from compas_cgal._geodesics import heat_geodesic_distances as _heat_geodesic_distances
1012
from compas_cgal._geodesics import HeatGeodesicSolver as _HeatGeodesicSolver
13+
from compas_cgal.types import PolylinesNumpy
1114
from compas_cgal.types import VerticesFaces
15+
from compas_cgal.types import VerticesFacesNumpy
1216

13-
__all__ = ["heat_geodesic_distances", "HeatGeodesicSolver"]
17+
__all__ = ["heat_geodesic_distances", "HeatGeodesicSolver", "geodesic_isolines_split", "geodesic_isolines"]
1418

1519

1620
def heat_geodesic_distances(mesh: VerticesFaces, sources: List[int]) -> NDArray:
@@ -102,3 +106,78 @@ def solve(self, sources: List[int]) -> NDArray:
102106
def num_vertices(self) -> int:
103107
"""Number of vertices in the mesh."""
104108
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)