Skip to main content

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}