Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds isoline extraction functionality to compas_cgal, enabling extraction of contour lines from scalar fields defined on mesh vertices. The implementation uses CGAL's refine_mesh_at_isolevel with optional post-processing (resampling and smoothing) in Python.
Key Changes:
- Implements C++ isoline extraction with CGAL's polygon mesh processing algorithms
- Provides Python wrapper with adaptive resampling and Laplacian smoothing options
- Adds comprehensive documentation and example demonstrating geodesic distance isolines
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/isolines.cpp |
New C++ implementation for extracting isolines from vertex scalar fields using CGAL |
src/compas_cgal/isolines.py |
Python wrapper providing high-level API with resampling and smoothing features |
src/compas_cgal/__init__.py |
Registers isolines module in plugin list |
CMakeLists.txt |
Adds isolines module to build configuration |
docs/examples/example_isolines.py |
Example demonstrating isoline extraction from geodesic distances on elephant mesh |
docs/examples/example_isolines.md |
Documentation page for isoline extraction example |
docs/api/compas_cgal.isolines.md |
API documentation stub for isolines module |
docs/api/compas_cgal.geodesics.md |
API documentation stub for geodesics module |
docs/api/compas_cgal.polylines.md |
API documentation stub for polylines module |
mkdocs.yml |
Updates documentation navigation to include new isolines content and API references |
requirements-dev.txt |
Adds documentation build dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| 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: |
There was a problem hiding this comment.
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.
|
|
||
| PolylineVisitor(const compas::Mesh& m, std::vector<std::vector<compas::Kernel::Point_3>>& p) | ||
| : mesh(m), polylines(p) {} | ||
|
|
||
| void start_new_polyline() { current.clear(); } | ||
| void add_node(ig_vertex v) { | ||
| vertex_descriptor vd = boost::get(boost::vertex_bundle, *graph_ptr)[v]; | ||
| current.push_back(mesh.point(vd)); | ||
| } | ||
| void end_polyline() { | ||
| if (current.size() >= 2) polylines.push_back(current); | ||
| } | ||
| const IsolineGraph* graph_ptr; | ||
| }; | ||
|
|
||
| std::vector<std::vector<compas::Kernel::Point_3>> polyline_points; | ||
| PolylineVisitor visitor(mesh, polyline_points); | ||
| visitor.graph_ptr = &iso_graph; |
There was a problem hiding this comment.
The PolylineVisitor struct has a public member graph_ptr that must be initialized before use. This is error-prone since the visitor is constructed without initializing this pointer, and it must be set manually afterward (line 120). Consider making graph_ptr a constructor parameter or making it a reference member to ensure it's always properly initialized and avoid potential null pointer dereferences.
| PolylineVisitor(const compas::Mesh& m, std::vector<std::vector<compas::Kernel::Point_3>>& p) | |
| : mesh(m), polylines(p) {} | |
| void start_new_polyline() { current.clear(); } | |
| void add_node(ig_vertex v) { | |
| vertex_descriptor vd = boost::get(boost::vertex_bundle, *graph_ptr)[v]; | |
| current.push_back(mesh.point(vd)); | |
| } | |
| void end_polyline() { | |
| if (current.size() >= 2) polylines.push_back(current); | |
| } | |
| const IsolineGraph* graph_ptr; | |
| }; | |
| std::vector<std::vector<compas::Kernel::Point_3>> polyline_points; | |
| PolylineVisitor visitor(mesh, polyline_points); | |
| visitor.graph_ptr = &iso_graph; | |
| const IsolineGraph& graph; | |
| PolylineVisitor(const compas::Mesh& m, | |
| std::vector<std::vector<compas::Kernel::Point_3>>& p, | |
| const IsolineGraph& g) | |
| : mesh(m), polylines(p), graph(g) {} | |
| void start_new_polyline() { current.clear(); } | |
| void add_node(ig_vertex v) { | |
| vertex_descriptor vd = boost::get(boost::vertex_bundle, graph)[v]; | |
| current.push_back(mesh.point(vd)); | |
| } | |
| void end_polyline() { | |
| if (current.size() >= 2) polylines.push_back(current); | |
| } | |
| }; | |
| std::vector<std::vector<compas::Kernel::Point_3>> polyline_points; | |
| PolylineVisitor visitor(mesh, polyline_points, iso_graph); |
| 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() |
There was a problem hiding this comment.
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.
| smin, smax = float(scalar_values.min()), float(scalar_values.max()) | ||
| isovalues = np.linspace(smin, smax, n + 2)[1:-1].tolist() | ||
|
|
||
| polylines = list(_isolines(V, F, scalar_values, isovalues, 0)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| 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("provide isovalues or n") | ||
| if isovalues is not None and n is not None: | ||
| raise ValueError("provide 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() | ||
|
|
||
| polylines = list(_isolines(V, F, scalar_values, isovalues, 0)) | ||
|
|
||
| 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 |
There was a problem hiding this comment.
The new isolines functionality lacks test coverage. Other modules in this repository have comprehensive test files (e.g., test_geodesics.py, test_booleans.py, test_meshing.py), but there is no test_isolines.py file. Adding tests would verify basic functionality, edge cases (empty results, single isovalue, multiple isovalues), parameter validation, and integration with resampling/smoothing features.
| if (isovalues.size() > 1) { | ||
| iso_spacing = std::abs(isovalues[1] - isovalues[0]); |
There was a problem hiding this comment.
The iso_spacing calculation assumes isovalues are sorted and uniformly spaced when computing isovalues[1] - isovalues[0]. If isovalues are provided in arbitrary order or with non-uniform spacing, this could lead to incorrect refinement offsets. Consider computing the minimum spacing between consecutive sorted isovalues or documenting this assumption in the function.
| 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() |
There was a problem hiding this comment.
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.
src/compas_cgal/isolines.py
Outdated
| smoothed = pts.copy() | ||
| for _ in range(iterations): | ||
| new_pts = smoothed.copy() | ||
| for i in range(1, len(pts) - 1): |
There was a problem hiding this comment.
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.
| for i in range(1, len(pts) - 1): | |
| for i in range(1, len(smoothed) - 1): |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@petrasvestartas any thoughts? |
|
@jf--- small corrections and will to merge and release. |
|
Upgraded cibuildwheel manylinux image from |
factor out
pure pythonisoline extraction forcompas_cgal