Skip to main content

viewport_lib/geometry/
isoline.rs

1//! CPU-side isoline (contour line) extraction from triangulated surfaces.
2//!
3//! An **isoline** is the set of points on a surface where a scalar field equals
4//! a given value (the *isovalue*).  This module implements a per-triangle
5//! edge-walk algorithm: for each triangle it checks which edges are crossed by
6//! the isovalue, linearly interpolates the crossing position, and emits a
7//! 2-point line segment.
8//!
9//! The extracted segments are returned in a form that can be passed directly to
10//! the existing [`crate::renderer::PolylineItem`] / polyline pipeline — no new
11//! GPU pipeline or shader is needed.
12//!
13//! # Usage
14//!
15//! ```ignore
16//! let item = IsolineItem {
17//!     positions: mesh_positions.clone(),
18//!     indices:   mesh_indices.clone(),
19//!     scalars:   per_vertex_pressure.clone(),
20//!     isovalues: vec![100.0, 200.0, 300.0],
21//!     color:     [1.0, 1.0, 0.0, 1.0],
22//!     line_width: 1.5,
23//!     ..IsolineItem::default()
24//! };
25//! let (positions, strip_lengths) = extract_isolines(&item);
26//! // feed into PolylineItem …
27//! ```
28
29/// Describes a contour-line extraction request for one mesh / scalar field.
30///
31/// `IsolineItem` is `#[non_exhaustive]` so future fields can be added without
32/// breaking existing callers that construct it via `Default::default()` +
33/// field assignment.
34#[non_exhaustive]
35#[derive(Debug, Clone)]
36pub struct IsolineItem {
37    /// Mesh vertex positions in local (object) space.  Must be the same length
38    /// as `scalars`.
39    pub positions: Vec<[f32; 3]>,
40
41    /// Triangle indices into `positions`.  Length must be a multiple of 3.
42    pub indices: Vec<u32>,
43
44    /// Per-vertex scalar values for the field to contour.  Must be the same
45    /// length as `positions`.
46    pub scalars: Vec<f32>,
47
48    /// Isovalues at which contour lines are extracted.  Each value produces an
49    /// independent set of line segments.
50    pub isovalues: Vec<f32>,
51
52    /// RGBA colour applied to every line segment.  Defaults to opaque white.
53    pub color: [f32; 4],
54
55    /// Line width in pixels.  Defaults to `1.0`.
56    pub line_width: f32,
57
58    /// Model matrix that transforms local-space positions to world space.
59    /// Defaults to [`glam::Mat4::IDENTITY`].
60    pub model_matrix: glam::Mat4,
61
62    /// Small offset along the surface face normal applied to extracted vertices
63    /// to prevent z-fighting with the underlying mesh.  Defaults to `0.001`.
64    pub depth_bias: f32,
65}
66
67impl Default for IsolineItem {
68    fn default() -> Self {
69        Self {
70            positions: Vec::new(),
71            indices: Vec::new(),
72            scalars: Vec::new(),
73            isovalues: Vec::new(),
74            color: [1.0, 1.0, 1.0, 1.0],
75            line_width: 1.0,
76            model_matrix: glam::Mat4::IDENTITY,
77            depth_bias: 0.001,
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Extraction
84// ---------------------------------------------------------------------------
85
86/// Extract isoline segments from a triangulated surface for all isovalues
87/// specified in `item`.
88///
89/// Returns `(positions, strip_lengths)` suitable for constructing a
90/// [`crate::renderer::PolylineItem`]:
91/// - `positions` — world-space positions of every line-segment endpoint,
92///   concatenated.
93/// - `strip_lengths` — number of vertices per strip.  Every segment is
94///   emitted as an independent 2-vertex strip, so each entry is `2`.
95///
96/// If `item` has empty `positions`, `indices`, or `scalars` the function
97/// returns `(vec![], vec![])` immediately.
98pub fn extract_isolines(item: &IsolineItem) -> (Vec<[f32; 3]>, Vec<u32>) {
99    // Fast-exit guards.
100    if item.positions.is_empty()
101        || item.indices.is_empty()
102        || item.scalars.is_empty()
103        || item.isovalues.is_empty()
104    {
105        return (Vec::new(), Vec::new());
106    }
107
108    // Validate consistency — mismatched arrays are a caller bug; skip silently.
109    if item.scalars.len() != item.positions.len() {
110        return (Vec::new(), Vec::new());
111    }
112
113    // Pre-allocate conservatively.
114    let tri_count = item.indices.len() / 3;
115    let iso_count = item.isovalues.len();
116    let mut out_positions: Vec<[f32; 3]> = Vec::with_capacity(tri_count * iso_count * 2);
117    let mut strip_lengths: Vec<u32> = Vec::with_capacity(tri_count * iso_count);
118
119    for &iso in &item.isovalues {
120        extract_for_isovalue(item, iso, &mut out_positions, &mut strip_lengths);
121    }
122
123    (out_positions, strip_lengths)
124}
125
126// ---------------------------------------------------------------------------
127// Per-isovalue inner loop
128// ---------------------------------------------------------------------------
129
130/// Edge-walk for a single isovalue.  Appends results to the caller's output
131/// vecs to avoid per-isovalue allocations.
132fn extract_for_isovalue(
133    item: &IsolineItem,
134    iso: f32,
135    out_positions: &mut Vec<[f32; 3]>,
136    strip_lengths: &mut Vec<u32>,
137) {
138    let positions = &item.positions;
139    let scalars = &item.scalars;
140    let model = item.model_matrix;
141    let bias = item.depth_bias;
142
143    let tri_count = item.indices.len() / 3;
144
145    for tri_idx in 0..tri_count {
146        let i0 = item.indices[tri_idx * 3] as usize;
147        let i1 = item.indices[tri_idx * 3 + 1] as usize;
148        let i2 = item.indices[tri_idx * 3 + 2] as usize;
149
150        // Bounds check — skip malformed index data.
151        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
152            continue;
153        }
154
155        let p0 = glam::Vec3::from(positions[i0]);
156        let p1 = glam::Vec3::from(positions[i1]);
157        let p2 = glam::Vec3::from(positions[i2]);
158
159        let s0 = scalars[i0];
160        let s1 = scalars[i1];
161        let s2 = scalars[i2];
162
163        // Face normal for depth bias.  Skip degenerate (zero-area) triangles.
164        let edge_a = p1 - p0;
165        let edge_b = p2 - p0;
166        let cross = edge_a.cross(edge_b);
167        let len_sq = cross.length_squared();
168        if len_sq < f32::EPSILON {
169            continue; // degenerate triangle
170        }
171        let face_normal = cross / len_sq.sqrt();
172
173        // Collect crossing points on the 3 edges.
174        let edges = [(p0, p1, s0, s1), (p1, p2, s1, s2), (p2, p0, s2, s0)];
175        let mut crossings: [Option<glam::Vec3>; 3] = [None; 3];
176
177        for (edge_slot, &(ea, eb, sa, sb)) in edges.iter().enumerate() {
178            if let Some(p) = edge_crossing(ea, eb, sa, sb, iso) {
179                crossings[edge_slot] = Some(p);
180            }
181        }
182
183        // Collect the valid crossing points.
184        let pts: Vec<glam::Vec3> = crossings.iter().filter_map(|&c| c).collect();
185
186        // We need exactly 2 points to form a line segment.
187        if pts.len() < 2 {
188            continue;
189        }
190
191        // If more than 2 (rare due to vertex exactly on isovalue), take first 2.
192        let a = pts[0];
193        let b = pts[1];
194
195        // Apply depth bias along the face normal (local space), then transform
196        // to world space using the model matrix.
197        let bias_vec = face_normal * bias;
198        let wa = transform_point(model, a + bias_vec);
199        let wb = transform_point(model, b + bias_vec);
200
201        out_positions.push(wa);
202        out_positions.push(wb);
203        strip_lengths.push(2);
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Helpers
209// ---------------------------------------------------------------------------
210
211/// Returns the interpolated position where the isovalue `iso` crosses the
212/// directed edge from `pa` (scalar `sa`) to `pb` (scalar `sb`), or `None`
213/// if the edge does not cross `iso`.
214///
215/// The edge is considered crossing when one endpoint is strictly below `iso`
216/// and the other is at or above `iso`.  If both endpoints are equal the edge
217/// is skipped.
218#[inline]
219fn edge_crossing(pa: glam::Vec3, pb: glam::Vec3, sa: f32, sb: f32, iso: f32) -> Option<glam::Vec3> {
220    // Skip if scalars are equal — no direction.
221    if (sb - sa).abs() < f32::EPSILON {
222        return None;
223    }
224
225    let crosses = (sa < iso && sb >= iso) || (sb < iso && sa >= iso);
226    if !crosses {
227        return None;
228    }
229
230    let t = (iso - sa) / (sb - sa);
231    Some(pa + t * (pb - pa))
232}
233
234/// Apply a 4×4 model matrix to a 3-D point (homogeneous w=1, divide by w).
235#[inline]
236fn transform_point(m: glam::Mat4, p: glam::Vec3) -> [f32; 3] {
237    m.transform_point3(p).to_array()
238}