|
| 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