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}