viewport_lib/resources/volume_mesh.rs
1//! Unstructured volume mesh processing : tet and hex cell topologies.
2//!
3//! Converts volumetric cell connectivity into a standard [`MeshData`] by
4//! extracting boundary faces (faces shared by exactly one cell) and computing
5//! area-weighted vertex normals. Per-cell scalar and color attributes are
6//! remapped to per-face attributes so the existing Phase 2 face-rendering path
7//! handles coloring without any new GPU infrastructure.
8//!
9//! # Cell conventions
10//!
11//! Every cell is stored as exactly **8 vertex indices**:
12//! - **Tet**: indices `[0..4]` are the 4 tet vertices; indices `[4..8]` are
13//! `u32::MAX` (sentinel).
14//! - **Hex**: all 8 indices are valid vertex positions.
15//! - **Mixed** meshes use the sentinel convention to distinguish per cell.
16//!
17//! Hex face winding follows the standard VTK unstructured-grid ordering so that
18//! outward normals are consistent when all cells have positive volume.
19
20use std::collections::HashMap;
21
22use super::types::{AttributeData, MeshData};
23
24/// Sentinel value that marks unused index slots in a tet cell stored as 8 indices.
25pub const TET_SENTINEL: u32 = u32::MAX;
26
27/// Input data for an unstructured volume mesh (tets, hexes, or mixed).
28///
29/// Each cell is represented as exactly 8 vertex indices. For tetrahedral
30/// cells, fill the last four indices with [`TET_SENTINEL`] (`u32::MAX`).
31///
32/// ```
33/// use viewport_lib::{VolumeMeshData, TET_SENTINEL};
34///
35/// // Two tets sharing vertices 0-1-2
36/// let data = VolumeMeshData {
37/// positions: vec![
38/// [0.0, 0.0, 0.0],
39/// [1.0, 0.0, 0.0],
40/// [0.5, 1.0, 0.0],
41/// [0.5, 0.5, 1.0],
42/// [0.5, 0.5, -1.0],
43/// ],
44/// cells: vec![
45/// [0, 1, 2, 3, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL],
46/// [0, 2, 1, 4, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL],
47/// ],
48/// ..Default::default()
49/// };
50/// ```
51#[non_exhaustive]
52#[derive(Default)]
53pub struct VolumeMeshData {
54 /// Vertex positions in local space.
55 pub positions: Vec<[f32; 3]>,
56
57 /// Cell connectivity : exactly 8 indices per cell.
58 ///
59 /// Tets: first 4 indices are the tet vertices; indices `[4..8]` must be
60 /// [`TET_SENTINEL`]. Hexes: all 8 indices are valid.
61 pub cells: Vec<[u32; 8]>,
62
63 /// Named per-cell scalar attributes (one `f32` per cell).
64 ///
65 /// Automatically remapped to boundary face scalars during upload so they
66 /// can be visualised via [`AttributeKind::Face`](super::types::AttributeKind::Face).
67 pub cell_scalars: HashMap<String, Vec<f32>>,
68
69 /// Named per-cell RGBA color attributes (one `[f32; 4]` per cell).
70 ///
71 /// Automatically remapped to boundary face colors during upload, rendered
72 /// via [`AttributeKind::FaceColor`](super::types::AttributeKind::FaceColor).
73 pub cell_colors: HashMap<String, Vec<[f32; 4]>>,
74}
75
76// ---------------------------------------------------------------------------
77// Tet face table
78// ---------------------------------------------------------------------------
79//
80// One face per vertex of the tet (face is opposite that vertex).
81// The winding listed here may be inward or outward depending on the tet's
82// signed volume; the geometric winding-correction step in
83// `extract_boundary_faces` normalises every boundary face to outward after
84// extraction, so the exact winding here does not matter for correctness.
85// We just need a consistent convention so the sorted-key boundary detection
86// works (both cells that share an interior face must produce the same key).
87
88const TET_FACES: [[usize; 3]; 4] = [
89 [1, 2, 3], // opposite v0
90 [0, 3, 2], // opposite v1
91 [0, 1, 3], // opposite v2
92 [0, 2, 1], // opposite v3
93];
94
95// ---------------------------------------------------------------------------
96// Hex face table
97// ---------------------------------------------------------------------------
98//
99// VTK hex vertex numbering used in `upload_volume_mesh_data` docs:
100//
101// 7 --- 6 top face
102// /| /|
103// 4 --- 5 |
104// | 3 --| 2 bottom face
105// |/ |/
106// 0 --- 1
107//
108// Six quad faces. Verified to produce outward normals (from-cell CCW):
109//
110// bottom (-Y): [0,1,2,3] : normal = (1,0,0)×(1,0,1) = (0,-1,0) ✓
111// top (+Y): [4,7,6,5] : normal = (0,0,1)×(1,0,1) = (0,+1,0) ✓
112// front (-Z): [0,4,5,1] : normal = (0,1,0)×(1,1,0) = (0,0,-1) ✓
113// back (+Z): [2,6,7,3] : normal = (0,1,0)×(-1,1,0)= (0,0,+1) ✓
114// left (-X): [0,3,7,4] : normal = (0,0,1)×(0,1,1) = (-1,0,0) ✓
115// right (+X): [1,5,6,2] : normal = (0,1,0)×(0,1,1) = (+1,0,0) ✓
116//
117// The geometric winding-correction step acts as a safety net in case any
118// cell is degenerate or oriented unexpectedly.
119
120const HEX_FACES: [[usize; 4]; 6] = [
121 [0, 1, 2, 3], // bottom (-Y)
122 [4, 7, 6, 5], // top (+Y)
123 [0, 4, 5, 1], // front (-Z)
124 [2, 6, 7, 3], // back (+Z)
125 [0, 3, 7, 4], // left (-X)
126 [1, 5, 6, 2], // right (+X)
127];
128
129// ---------------------------------------------------------------------------
130// Boundary extraction
131// ---------------------------------------------------------------------------
132
133/// A canonical (sorted) face key used for boundary detection.
134type FaceKey = (u32, u32, u32);
135
136/// Build a sorted key from three vertex indices.
137#[inline]
138fn face_key(a: u32, b: u32, c: u32) -> FaceKey {
139 let mut arr = [a, b, c];
140 arr.sort_unstable();
141 (arr[0], arr[1], arr[2])
142}
143
144/// Internal face record stored in the hash map during boundary extraction.
145struct FaceRecord {
146 /// Index of the first cell that produced this face.
147 cell_index: usize,
148 /// Original winding (preserves outward normal direction).
149 winding: [u32; 3],
150 /// How many cells have contributed this face. >1 means interior.
151 count: u32,
152 /// Centroid of the owning cell (in position space), used for winding correction.
153 cell_centroid: [f32; 3],
154}
155
156/// Convert [`VolumeMeshData`] into a standard [`MeshData`] by extracting the
157/// boundary surface and remapping per-cell attributes to per-face attributes.
158///
159/// This is the core of Phase 9: after this step the boundary mesh is uploaded
160/// via the existing [`upload_mesh_data`](super::ViewportGpuResources::upload_mesh_data)
161/// path and rendered exactly like any other surface mesh.
162pub(crate) fn extract_boundary_faces(data: &VolumeMeshData) -> MeshData {
163 let n_verts = data.positions.len();
164
165 // Accumulate triangles here; we'll build index buffer from unique vertices later.
166 // Strategy: collect boundary triangles as (winding, cell_index) then build
167 // a flat non-indexed triangle list and compute normals.
168 let mut face_map: HashMap<FaceKey, FaceRecord> = HashMap::new();
169
170 for (cell_idx, cell) in data.cells.iter().enumerate() {
171 let is_tet = cell[4] == TET_SENTINEL;
172
173 if is_tet {
174 // Centroid = average of 4 tet vertices.
175 let centroid = {
176 let mut c = [0.0f32; 3];
177 for &vi in &cell[0..4] {
178 let p = data.positions[vi as usize];
179 c[0] += p[0];
180 c[1] += p[1];
181 c[2] += p[2];
182 }
183 [c[0] / 4.0, c[1] / 4.0, c[2] / 4.0]
184 };
185
186 // 4 triangular faces
187 for face_local in &TET_FACES {
188 let a = cell[face_local[0]];
189 let b = cell[face_local[1]];
190 let c = cell[face_local[2]];
191 let key = face_key(a, b, c);
192 let entry = face_map.entry(key).or_insert(FaceRecord {
193 cell_index: cell_idx,
194 winding: [a, b, c],
195 count: 0,
196 cell_centroid: centroid,
197 });
198 entry.count += 1;
199 }
200 } else {
201 // Centroid = average of 8 hex vertices.
202 let centroid = {
203 let mut c = [0.0f32; 3];
204 for &vi in cell.iter() {
205 let p = data.positions[vi as usize];
206 c[0] += p[0];
207 c[1] += p[1];
208 c[2] += p[2];
209 }
210 [c[0] / 8.0, c[1] / 8.0, c[2] / 8.0]
211 };
212
213 // 6 quad faces, each split into 2 triangles
214 for quad in &HEX_FACES {
215 let v: [u32; 4] = [cell[quad[0]], cell[quad[1]], cell[quad[2]], cell[quad[3]]];
216 // tri 0: v0, v1, v2
217 {
218 let key = face_key(v[0], v[1], v[2]);
219 let entry = face_map.entry(key).or_insert(FaceRecord {
220 cell_index: cell_idx,
221 winding: [v[0], v[1], v[2]],
222 count: 0,
223 cell_centroid: centroid,
224 });
225 entry.count += 1;
226 }
227 // tri 1: v0, v2, v3
228 {
229 let key = face_key(v[0], v[2], v[3]);
230 let entry = face_map.entry(key).or_insert(FaceRecord {
231 cell_index: cell_idx,
232 winding: [v[0], v[2], v[3]],
233 count: 0,
234 cell_centroid: centroid,
235 });
236 entry.count += 1;
237 }
238 }
239 }
240 }
241
242 // Collect boundary triangles (count == 1) in a stable order.
243 let mut boundary: Vec<(usize, [u32; 3], [f32; 3])> = face_map
244 .into_values()
245 .filter(|r| r.count == 1)
246 .map(|r| (r.cell_index, r.winding, r.cell_centroid))
247 .collect();
248
249 // Sort by cell index for deterministic output (useful for testing).
250 boundary.sort_unstable_by_key(|(cell_idx, _, _)| *cell_idx);
251
252 // Geometric winding correction: ensure each boundary face's normal points
253 // outward (away from the owning cell centroid). This is a safety net for
254 // degenerate cells or unexpected orientations, and is also the primary
255 // correctness mechanism for tet faces where the table winding may be inward.
256 for (_, tri, cell_centroid) in &mut boundary {
257 let pa = data.positions[tri[0] as usize];
258 let pb = data.positions[tri[1] as usize];
259 let pc = data.positions[tri[2] as usize];
260
261 // Face normal (cross product of two edges; not normalized).
262 let ab = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
263 let ac = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
264 let normal = [
265 ab[1] * ac[2] - ab[2] * ac[1],
266 ab[2] * ac[0] - ab[0] * ac[2],
267 ab[0] * ac[1] - ab[1] * ac[0],
268 ];
269
270 // Direction from cell centroid to face centroid (outward reference).
271 let fc = [
272 (pa[0] + pb[0] + pc[0]) / 3.0,
273 (pa[1] + pb[1] + pc[1]) / 3.0,
274 (pa[2] + pb[2] + pc[2]) / 3.0,
275 ];
276 let out = [
277 fc[0] - cell_centroid[0],
278 fc[1] - cell_centroid[1],
279 fc[2] - cell_centroid[2],
280 ];
281
282 // If the face normal points inward, flip the winding.
283 let dot = normal[0] * out[0] + normal[1] * out[1] + normal[2] * out[2];
284 if dot < 0.0 {
285 tri.swap(1, 2);
286 }
287 }
288
289 let n_boundary_tris = boundary.len();
290
291 // Build flat triangle lists (positions & indices).
292 // We re-use original vertex indices and build a compact index buffer.
293 // To avoid the complexity of deduplication, we use the original vertex
294 // indices directly and build an index buffer. Normal accumulation uses
295 // the original vertex indices.
296
297 let mut indices: Vec<u32> = Vec::with_capacity(n_boundary_tris * 3);
298 // Track which original vertices are used by boundary faces.
299 let mut normal_accum: Vec<[f64; 3]> = vec![[0.0; 3]; n_verts];
300
301 for (_, tri, _) in &boundary {
302 indices.push(tri[0]);
303 indices.push(tri[1]);
304 indices.push(tri[2]);
305
306 // Accumulate area-weighted face normal to each vertex.
307 let pa = data.positions[tri[0] as usize];
308 let pb = data.positions[tri[1] as usize];
309 let pc = data.positions[tri[2] as usize];
310
311 let ab = [
312 (pb[0] - pa[0]) as f64,
313 (pb[1] - pa[1]) as f64,
314 (pb[2] - pa[2]) as f64,
315 ];
316 let ac = [
317 (pc[0] - pa[0]) as f64,
318 (pc[1] - pa[1]) as f64,
319 (pc[2] - pa[2]) as f64,
320 ];
321 // Cross product (area-weighted normal)
322 let n = [
323 ab[1] * ac[2] - ab[2] * ac[1],
324 ab[2] * ac[0] - ab[0] * ac[2],
325 ab[0] * ac[1] - ab[1] * ac[0],
326 ];
327 for &vi in tri {
328 let acc = &mut normal_accum[vi as usize];
329 acc[0] += n[0];
330 acc[1] += n[1];
331 acc[2] += n[2];
332 }
333 }
334
335 // Normalize accumulated normals.
336 let mut normals: Vec<[f32; 3]> = normal_accum
337 .iter()
338 .map(|n| {
339 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
340 if len > 1e-12 {
341 [
342 (n[0] / len) as f32,
343 (n[1] / len) as f32,
344 (n[2] / len) as f32,
345 ]
346 } else {
347 [0.0, 1.0, 0.0] // degenerate fallback
348 }
349 })
350 .collect();
351
352 // Ensure normals vec length matches positions.
353 normals.resize(n_verts, [0.0, 1.0, 0.0]);
354
355 // ---------------------------------------------------------------------------
356 // Build per-cell -> per-face attribute remapping
357 // ---------------------------------------------------------------------------
358
359 let mut attributes: HashMap<String, AttributeData> = HashMap::new();
360
361 for (name, cell_vals) in &data.cell_scalars {
362 let face_scalars: Vec<f32> = boundary
363 .iter()
364 .map(|(cell_idx, _, _)| cell_vals.get(*cell_idx).copied().unwrap_or(0.0))
365 .collect();
366 attributes.insert(name.clone(), AttributeData::Face(face_scalars));
367 }
368
369 for (name, cell_vals) in &data.cell_colors {
370 let face_colors: Vec<[f32; 4]> = boundary
371 .iter()
372 .map(|(cell_idx, _, _)| cell_vals.get(*cell_idx).copied().unwrap_or([1.0; 4]))
373 .collect();
374 attributes.insert(name.clone(), AttributeData::FaceColor(face_colors));
375 }
376
377 MeshData {
378 positions: data.positions.clone(),
379 normals,
380 indices,
381 uvs: None,
382 tangents: None,
383 attributes,
384 }
385}
386
387// ---------------------------------------------------------------------------
388// Tests
389// ---------------------------------------------------------------------------
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn single_tet() -> VolumeMeshData {
396 VolumeMeshData {
397 positions: vec![
398 [0.0, 0.0, 0.0],
399 [1.0, 0.0, 0.0],
400 [0.5, 1.0, 0.0],
401 [0.5, 0.5, 1.0],
402 ],
403 cells: vec![[
404 0,
405 1,
406 2,
407 3,
408 TET_SENTINEL,
409 TET_SENTINEL,
410 TET_SENTINEL,
411 TET_SENTINEL,
412 ]],
413 ..Default::default()
414 }
415 }
416
417 fn two_tets_sharing_face() -> VolumeMeshData {
418 // Two tets glued along face [0, 1, 2].
419 // Tet A: [0,1,2,3], Tet B: [0,2,1,4] (reversed to share face outwardly)
420 VolumeMeshData {
421 positions: vec![
422 [0.0, 0.0, 0.0],
423 [1.0, 0.0, 0.0],
424 [0.5, 1.0, 0.0],
425 [0.5, 0.5, 1.0],
426 [0.5, 0.5, -1.0],
427 ],
428 cells: vec![
429 [
430 0,
431 1,
432 2,
433 3,
434 TET_SENTINEL,
435 TET_SENTINEL,
436 TET_SENTINEL,
437 TET_SENTINEL,
438 ],
439 [
440 0,
441 2,
442 1,
443 4,
444 TET_SENTINEL,
445 TET_SENTINEL,
446 TET_SENTINEL,
447 TET_SENTINEL,
448 ],
449 ],
450 ..Default::default()
451 }
452 }
453
454 fn single_hex() -> VolumeMeshData {
455 VolumeMeshData {
456 positions: vec![
457 [0.0, 0.0, 0.0], // 0
458 [1.0, 0.0, 0.0], // 1
459 [1.0, 0.0, 1.0], // 2
460 [0.0, 0.0, 1.0], // 3
461 [0.0, 1.0, 0.0], // 4
462 [1.0, 1.0, 0.0], // 5
463 [1.0, 1.0, 1.0], // 6
464 [0.0, 1.0, 1.0], // 7
465 ],
466 cells: vec![[0, 1, 2, 3, 4, 5, 6, 7]],
467 ..Default::default()
468 }
469 }
470
471 #[test]
472 fn single_tet_has_four_boundary_faces() {
473 let data = single_tet();
474 let mesh = extract_boundary_faces(&data);
475 assert_eq!(
476 mesh.indices.len(),
477 4 * 3,
478 "single tet -> 4 boundary triangles"
479 );
480 }
481
482 #[test]
483 fn two_tets_sharing_face_eliminates_shared_face() {
484 let data = two_tets_sharing_face();
485 let mesh = extract_boundary_faces(&data);
486 // 4 + 4 - 2 = 6 boundary triangles (shared face contributes 2 tris
487 // that cancel, leaving 6)
488 assert_eq!(
489 mesh.indices.len(),
490 6 * 3,
491 "two tets sharing a face -> 6 boundary triangles"
492 );
493 }
494
495 #[test]
496 fn single_hex_has_twelve_boundary_triangles() {
497 let data = single_hex();
498 let mesh = extract_boundary_faces(&data);
499 // 6 quad faces × 2 triangles each = 12
500 assert_eq!(
501 mesh.indices.len(),
502 12 * 3,
503 "single hex -> 12 boundary triangles"
504 );
505 }
506
507 #[test]
508 fn normals_have_correct_length() {
509 let data = single_tet();
510 let mesh = extract_boundary_faces(&data);
511 assert_eq!(mesh.normals.len(), mesh.positions.len());
512 for n in &mesh.normals {
513 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
514 assert!(
515 (len - 1.0).abs() < 1e-5 || len < 1e-5,
516 "normal not unit: {n:?}"
517 );
518 }
519 }
520
521 #[test]
522 fn cell_scalar_remaps_to_face_attribute() {
523 let mut data = single_tet();
524 data.cell_scalars.insert("pressure".to_string(), vec![42.0]);
525 let mesh = extract_boundary_faces(&data);
526 match mesh.attributes.get("pressure") {
527 Some(AttributeData::Face(vals)) => {
528 assert_eq!(vals.len(), 4, "one value per boundary triangle");
529 for &v in vals {
530 assert_eq!(v, 42.0);
531 }
532 }
533 other => panic!("expected Face attribute, got {other:?}"),
534 }
535 }
536
537 #[test]
538 fn cell_color_remaps_to_face_color_attribute() {
539 let mut data = two_tets_sharing_face();
540 data.cell_colors.insert(
541 "label".to_string(),
542 vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
543 );
544 let mesh = extract_boundary_faces(&data);
545 match mesh.attributes.get("label") {
546 Some(AttributeData::FaceColor(colors)) => {
547 assert_eq!(colors.len(), 6, "6 boundary faces");
548 }
549 other => panic!("expected FaceColor attribute, got {other:?}"),
550 }
551 }
552
553 #[test]
554 fn positions_preserved_unchanged() {
555 let data = single_hex();
556 let mesh = extract_boundary_faces(&data);
557 assert_eq!(mesh.positions, data.positions);
558 }
559}