Skip to content

Commit 65f1dd8

Browse files
committed
add isolines module for scalar field isoline extraction
1 parent 370a589 commit 65f1dd8

File tree

5 files changed

+418
-0
lines changed

5 files changed

+418
-0
lines changed

CMakeLists.txt

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

241241
add_nanobind_module(_geodesics src/geodesics.cpp)
242+
add_nanobind_module(_isolines src/isolines.cpp)

docs/examples/example_isolines.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
from compas.colors import Color
5+
from compas.datastructures import Mesh
6+
from compas.geometry import Polyline
7+
from compas_viewer import Viewer
8+
9+
from compas_cgal.geodesics import heat_geodesic_distances
10+
from compas_cgal.isolines import isolines
11+
from compas_cgal.subdivision import mesh_subdivide_loop
12+
13+
# =============================================================================
14+
# Load mesh and subdivide
15+
# =============================================================================
16+
17+
FILE = Path(__file__).parent.parent.parent / "data" / "elephant.off"
18+
mesh = Mesh.from_off(FILE)
19+
mesh.quads_to_triangles()
20+
21+
V, F = mesh_subdivide_loop(mesh.to_vertices_and_faces(), k=2) # k=4 for proper smoothness
22+
mesh = Mesh.from_vertices_and_faces(V.tolist(), F.tolist())
23+
24+
# =============================================================================
25+
# Find source vertices: 4 feet and snout
26+
# =============================================================================
27+
28+
V = np.array(mesh.vertices_attributes("xyz"))
29+
30+
# feet: find lowest Y vertex in each XZ quadrant
31+
y_min = V[:, 1].min()
32+
low_verts = np.where(V[:, 1] < y_min + 0.15)[0]
33+
34+
feet = []
35+
for sx in [-1, 1]: # back/front (X)
36+
for sz in [-1, 1]: # left/right (Z)
37+
mask = (np.sign(V[low_verts, 0]) == sx) & (np.sign(V[low_verts, 2]) == sz)
38+
candidates = low_verts[mask]
39+
if len(candidates):
40+
foot = candidates[np.argmin(V[candidates, 1])]
41+
feet.append(int(foot))
42+
43+
# snout: max X (trunk tip)
44+
snout = int(np.argmax(V[:, 0]))
45+
46+
sources = feet + [snout]
47+
48+
# =============================================================================
49+
# Compute scalar field and extract isolines
50+
# =============================================================================
51+
52+
vf = mesh.to_vertices_and_faces()
53+
distances = heat_geodesic_distances(vf, sources)
54+
55+
for key, d in zip(mesh.vertices(), distances):
56+
mesh.vertex_attribute(key, "distance", d)
57+
58+
polylines = isolines(mesh, "distance", n=300, smoothing=0, resample=False)
59+
60+
# =============================================================================
61+
# Viz
62+
# =============================================================================
63+
64+
viewer = Viewer()
65+
66+
viewer.scene.add(mesh, show_lines=False)
67+
68+
for pts in polylines:
69+
points = [pts[i].tolist() for i in range(len(pts))]
70+
viewer.scene.add(Polyline(points), linecolor=Color.red(), lineswidth=3)
71+
72+
viewer.show()

src/compas_cgal/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
__all_plugins__ = [
1919
"compas_cgal.booleans",
2020
"compas_cgal.intersections",
21+
"compas_cgal.isolines",
2122
"compas_cgal.meshing",
2223
"compas_cgal.measure",
2324
"compas_cgal.reconstruction",

src/compas_cgal/isolines.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Isoline extraction from vertex scalar fields using CGAL."""
2+
3+
from typing import List
4+
5+
import numpy as np
6+
from numpy.typing import NDArray
7+
8+
from compas.datastructures import Mesh
9+
from compas_cgal._isolines import isolines as _isolines
10+
from compas_cgal.types import PolylinesNumpy
11+
12+
__all__ = ["isolines"]
13+
14+
15+
def _smooth_polyline(pts: NDArray, iterations: int = 1) -> NDArray:
16+
"""Apply Laplacian smoothing to a polyline, keeping endpoints fixed."""
17+
if len(pts) < 3:
18+
return pts
19+
smoothed = pts.copy()
20+
for _ in range(iterations):
21+
new_pts = smoothed.copy()
22+
for i in range(1, len(pts) - 1):
23+
new_pts[i] = (smoothed[i - 1] + smoothed[i + 1]) / 2
24+
smoothed = new_pts
25+
return smoothed
26+
27+
28+
def _segment_lengths(pts: NDArray) -> NDArray:
29+
"""Compute segment lengths for a polyline."""
30+
return np.linalg.norm(np.diff(pts, axis=0), axis=1)
31+
32+
33+
def _resample_polyline_adaptive(pts: NDArray, threshold: float = 2.0) -> NDArray:
34+
"""Resample only segments significantly longer than median.
35+
36+
Parameters
37+
----------
38+
pts : NDArray
39+
Polyline points (Nx3).
40+
threshold : float
41+
Segments longer than threshold * median_length get subdivided.
42+
43+
Returns
44+
-------
45+
NDArray
46+
Resampled polyline.
47+
"""
48+
if len(pts) < 3:
49+
return pts
50+
51+
lengths = _segment_lengths(pts)
52+
median_len = np.median(lengths)
53+
54+
if median_len == 0:
55+
return pts
56+
57+
result = [pts[0]]
58+
for i in range(len(pts) - 1):
59+
p0, p1 = pts[i], pts[i + 1]
60+
seg_len = lengths[i]
61+
62+
# subdivide long segments proportionally
63+
if seg_len > threshold * median_len:
64+
n_subdivs = int(np.ceil(seg_len / median_len))
65+
for j in range(1, n_subdivs):
66+
t = j / n_subdivs
67+
result.append(p0 * (1 - t) + p1 * t)
68+
69+
result.append(p1)
70+
71+
return np.array(result)
72+
73+
74+
def _resample_polyline(pts: NDArray, factor: int) -> NDArray:
75+
"""Resample a polyline by inserting interpolated points between each pair."""
76+
if len(pts) < 2 or factor < 2:
77+
return pts
78+
result = [pts[0]]
79+
for i in range(len(pts) - 1):
80+
p0, p1 = pts[i], pts[i + 1]
81+
for j in range(1, factor):
82+
t = j / factor
83+
result.append(p0 * (1 - t) + p1 * t)
84+
result.append(p1)
85+
return np.array(result)
86+
87+
88+
def isolines(
89+
mesh: Mesh,
90+
scalars: str,
91+
isovalues: List[float] | None = None,
92+
n: int | None = None,
93+
resample: int | bool = True,
94+
smoothing: int = 0,
95+
) -> PolylinesNumpy:
96+
"""Extract isoline polylines from vertex scalar field.
97+
98+
Uses CGAL's refine_mesh_at_isolevel to extract isolines from a scalar
99+
field defined at mesh vertices.
100+
101+
Parameters
102+
----------
103+
mesh : :class:`compas.datastructures.Mesh`
104+
A triangulated mesh.
105+
scalars : str
106+
Name of the vertex attribute containing scalar values.
107+
isovalues : List[float], optional
108+
Explicit isovalue thresholds for isoline extraction.
109+
n : int, optional
110+
Number of evenly spaced isovalues between scalar min and max.
111+
The isovalues will exclude the endpoints.
112+
resample : int or bool, optional
113+
Polyline resampling mode. If True (default), adaptively resample
114+
segments longer than 2x median length. If int > 1, uniformly
115+
subdivide each segment into that many parts. If False or 1, disable.
116+
smoothing : int, optional
117+
Number of Laplacian smoothing iterations to apply to polylines.
118+
Default is 0 (no smoothing).
119+
120+
Returns
121+
-------
122+
:attr:`compas_cgal.types.PolylinesNumpy`
123+
List of polyline segments as Nx3 arrays of points.
124+
125+
Raises
126+
------
127+
ValueError
128+
If neither or both of `isovalues` and `n` are provided.
129+
130+
Examples
131+
--------
132+
>>> from compas.datastructures import Mesh
133+
>>> from compas.geometry import Sphere
134+
>>> from compas_cgal.geodesics import heat_geodesic_distances
135+
>>> from compas_cgal.isolines import isolines
136+
>>> sphere = Sphere(1.0)
137+
>>> mesh = Mesh.from_shape(sphere, u=32, v=32)
138+
>>> mesh.quads_to_triangles()
139+
>>> vf = mesh.to_vertices_and_faces()
140+
>>> distances = heat_geodesic_distances(vf, [0])
141+
>>> for key, d in zip(mesh.vertices(), distances):
142+
... mesh.vertex_attribute(key, "distance", d)
143+
>>> polylines = isolines(mesh, "distance", n=5)
144+
145+
"""
146+
V = np.asarray(mesh.vertices_attributes("xyz"), dtype=np.float64, order="C")
147+
F = np.asarray([mesh.face_vertices(f) for f in mesh.faces()], dtype=np.int32, order="C")
148+
scalar_values = np.asarray(mesh.vertices_attribute(scalars), dtype=np.float64, order="C").reshape(-1, 1)
149+
150+
if isovalues is None and n is None:
151+
raise ValueError("provide isovalues or n")
152+
if isovalues is not None and n is not None:
153+
raise ValueError("provide isovalues or n, not both")
154+
155+
if n is not None:
156+
smin, smax = float(scalar_values.min()), float(scalar_values.max())
157+
isovalues = np.linspace(smin, smax, n + 2)[1:-1].tolist()
158+
159+
polylines = list(_isolines(V, F, scalar_values, isovalues, 0))
160+
161+
if resample is True:
162+
polylines = [_resample_polyline_adaptive(pl) for pl in polylines]
163+
elif isinstance(resample, int) and resample > 1:
164+
polylines = [_resample_polyline(pl, resample) for pl in polylines]
165+
166+
if smoothing > 0:
167+
polylines = [_smooth_polyline(pl, smoothing) for pl in polylines]
168+
169+
return polylines

0 commit comments

Comments
 (0)