-
Notifications
You must be signed in to change notification settings - Fork 9
Jf/isolines #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Jf/isolines #63
Changes from all commits
76e99a8
370a589
65f1dd8
fa9c238
8db7a39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # ::: compas_cgal.geodesics |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # ::: compas_cgal.isolines |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # ::: compas_cgal.polylines |
| 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: | ||
|
|
||
| * 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" | ||
| ``` | ||
| 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() |
| 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): | ||||||
|
||||||
| for i in range(1, len(pts) - 1): | |
| for i in range(1, len(smoothed) - 1): |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
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
AI
Dec 17, 2025
There was a problem hiding this comment.
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
AI
Dec 17, 2025
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.