Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,4 @@ add_nanobind_module(_subdivision src/subdivision.cpp)
add_nanobind_module(_polylines src/polylines.cpp)

add_nanobind_module(_geodesics src/geodesics.cpp)
add_nanobind_module(_isolines src/isolines.cpp)
1 change: 1 addition & 0 deletions docs/api/compas_cgal.geodesics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# ::: compas_cgal.geodesics
1 change: 1 addition & 0 deletions docs/api/compas_cgal.isolines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# ::: compas_cgal.isolines
1 change: 1 addition & 0 deletions docs/api/compas_cgal.polylines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# ::: compas_cgal.polylines
16 changes: 16 additions & 0 deletions docs/examples/example_isolines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Isolines from Scalar Fields

This example demonstrates isoline extraction from arbitrary vertex scalar fields using COMPAS CGAL.

The visualization shows an elephant mesh with isolines extracted from geodesic distances computed from five source points: the four feet and the snout.

Key Features:
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing colon after "Key Features" in the markdown documentation. Markdown list formatting typically requires a colon after an introductory phrase before the bulleted list begins.

Copilot uses AI. Check for mistakes.

* Generic isoline extraction from any vertex scalar field via ``isolines``
* Support for explicit isovalues or automatic even spacing
* Adaptive resampling for smoother output
* Optional Laplacian smoothing

```python
---8<--- "docs/examples/example_isolines.py"
```
72 changes: 72 additions & 0 deletions docs/examples/example_isolines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pathlib import Path

import numpy as np
from compas.colors import Color
from compas.datastructures import Mesh
from compas.geometry import Polyline
from compas_viewer import Viewer

from compas_cgal.geodesics import heat_geodesic_distances
from compas_cgal.isolines import isolines
from compas_cgal.subdivision import mesh_subdivide_loop

# =============================================================================
# Load mesh and subdivide
# =============================================================================

FILE = Path(__file__).parent.parent.parent / "data" / "elephant.off"
mesh = Mesh.from_off(FILE)
mesh.quads_to_triangles()

V, F = mesh_subdivide_loop(mesh.to_vertices_and_faces(), k=2) # k=4 for proper smoothness
mesh = Mesh.from_vertices_and_faces(V.tolist(), F.tolist())

# =============================================================================
# Find source vertices: 4 feet and snout
# =============================================================================

V = np.array(mesh.vertices_attributes("xyz"))

# feet: find lowest Y vertex in each XZ quadrant
y_min = V[:, 1].min()
low_verts = np.where(V[:, 1] < y_min + 0.15)[0]

feet = []
for sx in [-1, 1]: # back/front (X)
for sz in [-1, 1]: # left/right (Z)
mask = (np.sign(V[low_verts, 0]) == sx) & (np.sign(V[low_verts, 2]) == sz)
candidates = low_verts[mask]
if len(candidates):
foot = candidates[np.argmin(V[candidates, 1])]
feet.append(int(foot))

# snout: max X (trunk tip)
snout = int(np.argmax(V[:, 0]))

sources = feet + [snout]

# =============================================================================
# Compute scalar field and extract isolines
# =============================================================================

vf = mesh.to_vertices_and_faces()
distances = heat_geodesic_distances(vf, sources)

for key, d in zip(mesh.vertices(), distances):
mesh.vertex_attribute(key, "distance", d)

polylines = isolines(mesh, "distance", n=300, smoothing=0, resample=False)

# =============================================================================
# Viz
# =============================================================================

viewer = Viewer()

viewer.scene.add(mesh, show_lines=False)

for pts in polylines:
points = [pts[i].tolist() for i in range(len(pts))]
viewer.scene.add(Polyline(points), linecolor=Color.red(), lineswidth=3)

viewer.show()
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ nav:
- Booleans: examples/example_booleans.md
- Geodesics: examples/example_geodesics.md
- Intersections: examples/example_intersections.md
- Isolines: examples/example_isolines.md
- Meshing: examples/example_meshing.md
- Meshing Dual: examples/example_meshing_dual.md
- Projection: examples/example_projection.md
Expand All @@ -162,9 +163,12 @@ nav:
- Triangulation: examples/example_triangulation.md
- API Reference:
- compas_cgal.booleans: api/compas_cgal.booleans.md
- compas_cgal.geodesics: api/compas_cgal.geodesics.md
- compas_cgal.intersections: api/compas_cgal.intersections.md
- compas_cgal.isolines: api/compas_cgal.isolines.md
- compas_cgal.measure: api/compas_cgal.measure.md
- compas_cgal.meshing: api/compas_cgal.meshing.md
- compas_cgal.polylines: api/compas_cgal.polylines.md
- compas_cgal.projection: api/compas_cgal.projection.md
- compas_cgal.reconstruction: api/compas_cgal.reconstruction.md
- compas_cgal.skeletonization: api/compas_cgal.skeletonization.md
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ bump-my-version
cibuildwheel
compas_invocations2
invoke >=0.14
-r requirements-docs.txt
nanobind >=1.3.2
pytest >=7.0.0
pytest-cov >=4.0.0
Expand Down
1 change: 1 addition & 0 deletions src/compas_cgal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
__all_plugins__ = [
"compas_cgal.booleans",
"compas_cgal.intersections",
"compas_cgal.isolines",
"compas_cgal.meshing",
"compas_cgal.measure",
"compas_cgal.reconstruction",
Expand Down
169 changes: 169 additions & 0 deletions src/compas_cgal/isolines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Isoline extraction from vertex scalar fields using CGAL."""

from typing import List

import numpy as np
from numpy.typing import NDArray

from compas.datastructures import Mesh
from compas_cgal._isolines import isolines as _isolines
from compas_cgal.types import PolylinesNumpy

__all__ = ["isolines"]


def _smooth_polyline(pts: NDArray, iterations: int = 1) -> NDArray:
"""Apply Laplacian smoothing to a polyline, keeping endpoints fixed."""
if len(pts) < 3:
return pts
smoothed = pts.copy()
for _ in range(iterations):
new_pts = smoothed.copy()
for i in range(1, len(pts) - 1):
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The smoothing function uses range(1, len(pts) - 1) but iterates based on the original pts length rather than smoothed. This is correct for the algorithm but could be clearer. Also, the reference to smoothed[i - 1] and smoothed[i + 1] in the loop should consistently reference the current state, not the original points array, to avoid confusion.

Suggested change
for i in range(1, len(pts) - 1):
for i in range(1, len(smoothed) - 1):

Copilot uses AI. Check for mistakes.
new_pts[i] = (smoothed[i - 1] + smoothed[i + 1]) / 2
smoothed = new_pts
return smoothed


def _segment_lengths(pts: NDArray) -> NDArray:
"""Compute segment lengths for a polyline."""
return np.linalg.norm(np.diff(pts, axis=0), axis=1)


def _resample_polyline_adaptive(pts: NDArray, threshold: float = 2.0) -> NDArray:
"""Resample only segments significantly longer than median.

Parameters
----------
pts : NDArray
Polyline points (Nx3).
threshold : float
Segments longer than threshold * median_length get subdivided.

Returns
-------
NDArray
Resampled polyline.
"""
if len(pts) < 3:
return pts

lengths = _segment_lengths(pts)
median_len = np.median(lengths)

if median_len == 0:
return pts

result = [pts[0]]
for i in range(len(pts) - 1):
p0, p1 = pts[i], pts[i + 1]
seg_len = lengths[i]

# subdivide long segments proportionally
if seg_len > threshold * median_len:
n_subdivs = int(np.ceil(seg_len / median_len))
for j in range(1, n_subdivs):
t = j / n_subdivs
result.append(p0 * (1 - t) + p1 * t)

result.append(p1)

return np.array(result)


def _resample_polyline(pts: NDArray, factor: int) -> NDArray:
"""Resample a polyline by inserting interpolated points between each pair."""
if len(pts) < 2 or factor < 2:
return pts
result = [pts[0]]
for i in range(len(pts) - 1):
p0, p1 = pts[i], pts[i + 1]
for j in range(1, factor):
t = j / factor
result.append(p0 * (1 - t) + p1 * t)
result.append(p1)
return np.array(result)


def isolines(
mesh: Mesh,
scalars: str,
isovalues: List[float] | None = None,
n: int | None = None,
resample: int | bool = True,
smoothing: int = 0,
) -> PolylinesNumpy:
"""Extract isoline polylines from vertex scalar field.

Uses CGAL's refine_mesh_at_isolevel to extract isolines from a scalar
field defined at mesh vertices.

Parameters
----------
mesh : :class:`compas.datastructures.Mesh`
A triangulated mesh.
scalars : str
Name of the vertex attribute containing scalar values.
isovalues : List[float], optional
Explicit isovalue thresholds for isoline extraction.
n : int, optional
Number of evenly spaced isovalues between scalar min and max.
The isovalues will exclude the endpoints.
resample : int or bool, optional
Polyline resampling mode. If True (default), adaptively resample
segments longer than 2x median length. If int > 1, uniformly
subdivide each segment into that many parts. If False or 1, disable.
smoothing : int, optional
Number of Laplacian smoothing iterations to apply to polylines.
Default is 0 (no smoothing).

Returns
-------
:attr:`compas_cgal.types.PolylinesNumpy`
List of polyline segments as Nx3 arrays of points.

Raises
------
ValueError
If neither or both of `isovalues` and `n` are provided.

Examples
--------
>>> from compas.datastructures import Mesh
>>> from compas.geometry import Sphere
>>> from compas_cgal.geodesics import heat_geodesic_distances
>>> from compas_cgal.isolines import isolines
>>> sphere = Sphere(1.0)
>>> mesh = Mesh.from_shape(sphere, u=32, v=32)
>>> mesh.quads_to_triangles()
>>> vf = mesh.to_vertices_and_faces()
>>> distances = heat_geodesic_distances(vf, [0])
>>> for key, d in zip(mesh.vertices(), distances):
... mesh.vertex_attribute(key, "distance", d)
>>> polylines = isolines(mesh, "distance", n=5)

"""
V = np.asarray(mesh.vertices_attributes("xyz"), dtype=np.float64, order="C")
F = np.asarray([mesh.face_vertices(f) for f in mesh.faces()], dtype=np.int32, order="C")
scalar_values = np.asarray(mesh.vertices_attribute(scalars), dtype=np.float64, order="C").reshape(-1, 1)

if isovalues is None and n is None:
raise ValueError("Either 'isovalues' or 'n' must be provided.")
if isovalues is not None and n is not None:
raise ValueError("Provide exactly one of 'isovalues' or 'n' (not both).")

if n is not None:
smin, smax = float(scalar_values.min()), float(scalar_values.max())
isovalues = np.linspace(smin, smax, n + 2)[1:-1].tolist()
Comment on lines +155 to +157
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter validation allows n=0 which would result in an empty list of isovalues. When n=0, np.linspace(smin, smax, 2)[1:-1] returns an empty array. Consider adding validation to ensure n > 0 when provided, similar to how the function validates that either isovalues or n must be provided.

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +157
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When n is provided and smin == smax (i.e., all scalar values are identical), the function will generate isovalues at the same value. This edge case could result in unexpected behavior. Consider adding validation or a warning when the scalar field has no variation.

Copilot uses AI. Check for mistakes.

polylines = list(_isolines(V, F, scalar_values, isovalues, 0))
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refine parameter is hardcoded to 0. The C++ function pmp_isolines accepts a refine parameter to enable local mesh refinement around isolines for better quality, but this is not exposed to the Python API. Consider adding a refine parameter to the Python function signature to allow users to control mesh refinement when extracting isolines.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


if resample is True:
polylines = [_resample_polyline_adaptive(pl) for pl in polylines]
elif isinstance(resample, int) and resample > 1:
polylines = [_resample_polyline(pl, resample) for pl in polylines]

if smoothing > 0:
polylines = [_smooth_polyline(pl, smoothing) for pl in polylines]

return polylines
Loading
Loading