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}