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]; c[1] += p[1]; c[2] += p[2];
180                }
181                [c[0] / 4.0, c[1] / 4.0, c[2] / 4.0]
182            };
183
184            // 4 triangular faces
185            for face_local in &TET_FACES {
186                let a = cell[face_local[0]];
187                let b = cell[face_local[1]];
188                let c = cell[face_local[2]];
189                let key = face_key(a, b, c);
190                let entry = face_map.entry(key).or_insert(FaceRecord {
191                    cell_index: cell_idx,
192                    winding: [a, b, c],
193                    count: 0,
194                    cell_centroid: centroid,
195                });
196                entry.count += 1;
197            }
198        } else {
199            // Centroid = average of 8 hex vertices.
200            let centroid = {
201                let mut c = [0.0f32; 3];
202                for &vi in cell.iter() {
203                    let p = data.positions[vi as usize];
204                    c[0] += p[0]; c[1] += p[1]; c[2] += p[2];
205                }
206                [c[0] / 8.0, c[1] / 8.0, c[2] / 8.0]
207            };
208
209            // 6 quad faces, each split into 2 triangles
210            for quad in &HEX_FACES {
211                let v: [u32; 4] = [
212                    cell[quad[0]],
213                    cell[quad[1]],
214                    cell[quad[2]],
215                    cell[quad[3]],
216                ];
217                // tri 0: v0, v1, v2
218                {
219                    let key = face_key(v[0], v[1], v[2]);
220                    let entry = face_map.entry(key).or_insert(FaceRecord {
221                        cell_index: cell_idx,
222                        winding: [v[0], v[1], v[2]],
223                        count: 0,
224                        cell_centroid: centroid,
225                    });
226                    entry.count += 1;
227                }
228                // tri 1: v0, v2, v3
229                {
230                    let key = face_key(v[0], v[2], v[3]);
231                    let entry = face_map.entry(key).or_insert(FaceRecord {
232                        cell_index: cell_idx,
233                        winding: [v[0], v[2], v[3]],
234                        count: 0,
235                        cell_centroid: centroid,
236                    });
237                    entry.count += 1;
238                }
239            }
240        }
241    }
242
243    // Collect boundary triangles (count == 1) in a stable order.
244    let mut boundary: Vec<(usize, [u32; 3], [f32; 3])> = face_map
245        .into_values()
246        .filter(|r| r.count == 1)
247        .map(|r| (r.cell_index, r.winding, r.cell_centroid))
248        .collect();
249
250    // Sort by cell index for deterministic output (useful for testing).
251    boundary.sort_unstable_by_key(|(cell_idx, _, _)| *cell_idx);
252
253    // Geometric winding correction: ensure each boundary face's normal points
254    // outward (away from the owning cell centroid). This is a safety net for
255    // degenerate cells or unexpected orientations, and is also the primary
256    // correctness mechanism for tet faces where the table winding may be inward.
257    for (_, tri, cell_centroid) in &mut boundary {
258        let pa = data.positions[tri[0] as usize];
259        let pb = data.positions[tri[1] as usize];
260        let pc = data.positions[tri[2] as usize];
261
262        // Face normal (cross product of two edges; not normalized).
263        let ab = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
264        let ac = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
265        let normal = [
266            ab[1] * ac[2] - ab[2] * ac[1],
267            ab[2] * ac[0] - ab[0] * ac[2],
268            ab[0] * ac[1] - ab[1] * ac[0],
269        ];
270
271        // Direction from cell centroid to face centroid (outward reference).
272        let fc = [
273            (pa[0] + pb[0] + pc[0]) / 3.0,
274            (pa[1] + pb[1] + pc[1]) / 3.0,
275            (pa[2] + pb[2] + pc[2]) / 3.0,
276        ];
277        let out = [
278            fc[0] - cell_centroid[0],
279            fc[1] - cell_centroid[1],
280            fc[2] - cell_centroid[2],
281        ];
282
283        // If the face normal points inward, flip the winding.
284        let dot = normal[0] * out[0] + normal[1] * out[1] + normal[2] * out[2];
285        if dot < 0.0 {
286            tri.swap(1, 2);
287        }
288    }
289
290    let n_boundary_tris = boundary.len();
291
292    // Build flat triangle lists (positions & indices).
293    // We re-use original vertex indices and build a compact index buffer.
294    // To avoid the complexity of deduplication, we use the original vertex
295    // indices directly and build an index buffer.  Normal accumulation uses
296    // the original vertex indices.
297
298    let mut indices: Vec<u32> = Vec::with_capacity(n_boundary_tris * 3);
299    // Track which original vertices are used by boundary faces.
300    let mut normal_accum: Vec<[f64; 3]> = vec![[0.0; 3]; n_verts];
301
302    for (_, tri, _) in &boundary {
303        indices.push(tri[0]);
304        indices.push(tri[1]);
305        indices.push(tri[2]);
306
307        // Accumulate area-weighted face normal to each vertex.
308        let pa = data.positions[tri[0] as usize];
309        let pb = data.positions[tri[1] as usize];
310        let pc = data.positions[tri[2] as usize];
311
312        let ab = [
313            (pb[0] - pa[0]) as f64,
314            (pb[1] - pa[1]) as f64,
315            (pb[2] - pa[2]) as f64,
316        ];
317        let ac = [
318            (pc[0] - pa[0]) as f64,
319            (pc[1] - pa[1]) as f64,
320            (pc[2] - pa[2]) as f64,
321        ];
322        // Cross product (area-weighted normal)
323        let n = [
324            ab[1] * ac[2] - ab[2] * ac[1],
325            ab[2] * ac[0] - ab[0] * ac[2],
326            ab[0] * ac[1] - ab[1] * ac[0],
327        ];
328        for &vi in tri {
329            let acc = &mut normal_accum[vi as usize];
330            acc[0] += n[0];
331            acc[1] += n[1];
332            acc[2] += n[2];
333        }
334    }
335
336    // Normalize accumulated normals.
337    let mut normals: Vec<[f32; 3]> = normal_accum
338        .iter()
339        .map(|n| {
340            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
341            if len > 1e-12 {
342                [(n[0] / len) as f32, (n[1] / len) as f32, (n[2] / len) as f32]
343            } else {
344                [0.0, 1.0, 0.0] // degenerate fallback
345            }
346        })
347        .collect();
348
349    // Ensure normals vec length matches positions.
350    normals.resize(n_verts, [0.0, 1.0, 0.0]);
351
352    // ---------------------------------------------------------------------------
353    // Build per-cell → per-face attribute remapping
354    // ---------------------------------------------------------------------------
355
356    let mut attributes: HashMap<String, AttributeData> = HashMap::new();
357
358    for (name, cell_vals) in &data.cell_scalars {
359        let face_scalars: Vec<f32> = boundary
360            .iter()
361            .map(|(cell_idx, _, _)| cell_vals.get(*cell_idx).copied().unwrap_or(0.0))
362            .collect();
363        attributes.insert(name.clone(), AttributeData::Face(face_scalars));
364    }
365
366    for (name, cell_vals) in &data.cell_colors {
367        let face_colors: Vec<[f32; 4]> = boundary
368            .iter()
369            .map(|(cell_idx, _, _)| cell_vals.get(*cell_idx).copied().unwrap_or([1.0; 4]))
370            .collect();
371        attributes.insert(name.clone(), AttributeData::FaceColor(face_colors));
372    }
373
374    MeshData {
375        positions: data.positions.clone(),
376        normals,
377        indices,
378        uvs: None,
379        tangents: None,
380        attributes,
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn single_tet() -> VolumeMeshData {
393        VolumeMeshData {
394            positions: vec![
395                [0.0, 0.0, 0.0],
396                [1.0, 0.0, 0.0],
397                [0.5, 1.0, 0.0],
398                [0.5, 0.5, 1.0],
399            ],
400            cells: vec![[0, 1, 2, 3, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL]],
401            ..Default::default()
402        }
403    }
404
405    fn two_tets_sharing_face() -> VolumeMeshData {
406        // Two tets glued along face [0, 1, 2].
407        // Tet A: [0,1,2,3], Tet B: [0,2,1,4]  (reversed to share face outwardly)
408        VolumeMeshData {
409            positions: vec![
410                [0.0, 0.0, 0.0],
411                [1.0, 0.0, 0.0],
412                [0.5, 1.0, 0.0],
413                [0.5, 0.5, 1.0],
414                [0.5, 0.5, -1.0],
415            ],
416            cells: vec![
417                [0, 1, 2, 3, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL],
418                [0, 2, 1, 4, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL, TET_SENTINEL],
419            ],
420            ..Default::default()
421        }
422    }
423
424    fn single_hex() -> VolumeMeshData {
425        VolumeMeshData {
426            positions: vec![
427                [0.0, 0.0, 0.0], // 0
428                [1.0, 0.0, 0.0], // 1
429                [1.0, 0.0, 1.0], // 2
430                [0.0, 0.0, 1.0], // 3
431                [0.0, 1.0, 0.0], // 4
432                [1.0, 1.0, 0.0], // 5
433                [1.0, 1.0, 1.0], // 6
434                [0.0, 1.0, 1.0], // 7
435            ],
436            cells: vec![[0, 1, 2, 3, 4, 5, 6, 7]],
437            ..Default::default()
438        }
439    }
440
441    #[test]
442    fn single_tet_has_four_boundary_faces() {
443        let data = single_tet();
444        let mesh = extract_boundary_faces(&data);
445        assert_eq!(mesh.indices.len(), 4 * 3, "single tet → 4 boundary triangles");
446    }
447
448    #[test]
449    fn two_tets_sharing_face_eliminates_shared_face() {
450        let data = two_tets_sharing_face();
451        let mesh = extract_boundary_faces(&data);
452        // 4 + 4 - 2 = 6 boundary triangles (shared face contributes 2 tris
453        // that cancel, leaving 6)
454        assert_eq!(
455            mesh.indices.len(),
456            6 * 3,
457            "two tets sharing a face → 6 boundary triangles"
458        );
459    }
460
461    #[test]
462    fn single_hex_has_twelve_boundary_triangles() {
463        let data = single_hex();
464        let mesh = extract_boundary_faces(&data);
465        // 6 quad faces × 2 triangles each = 12
466        assert_eq!(mesh.indices.len(), 12 * 3, "single hex → 12 boundary triangles");
467    }
468
469    #[test]
470    fn normals_have_correct_length() {
471        let data = single_tet();
472        let mesh = extract_boundary_faces(&data);
473        assert_eq!(mesh.normals.len(), mesh.positions.len());
474        for n in &mesh.normals {
475            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
476            assert!(
477                (len - 1.0).abs() < 1e-5 || len < 1e-5,
478                "normal not unit: {n:?}"
479            );
480        }
481    }
482
483    #[test]
484    fn cell_scalar_remaps_to_face_attribute() {
485        let mut data = single_tet();
486        data.cell_scalars.insert("pressure".to_string(), vec![42.0]);
487        let mesh = extract_boundary_faces(&data);
488        match mesh.attributes.get("pressure") {
489            Some(AttributeData::Face(vals)) => {
490                assert_eq!(vals.len(), 4, "one value per boundary triangle");
491                for &v in vals {
492                    assert_eq!(v, 42.0);
493                }
494            }
495            other => panic!("expected Face attribute, got {other:?}"),
496        }
497    }
498
499    #[test]
500    fn cell_color_remaps_to_face_color_attribute() {
501        let mut data = two_tets_sharing_face();
502        data.cell_colors.insert(
503            "label".to_string(),
504            vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
505        );
506        let mesh = extract_boundary_faces(&data);
507        match mesh.attributes.get("label") {
508            Some(AttributeData::FaceColor(colors)) => {
509                assert_eq!(colors.len(), 6, "6 boundary faces");
510            }
511            other => panic!("expected FaceColor attribute, got {other:?}"),
512        }
513    }
514
515    #[test]
516    fn positions_preserved_unchanged() {
517        let data = single_hex();
518        let mesh = extract_boundary_faces(&data);
519        assert_eq!(mesh.positions, data.positions);
520    }
521}