Skip to main content

viewport_lib/resources/
volume_mesh.rs

1//! Unstructured volume mesh processing : tet, pyramid, wedge, 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 colour attributes are
6//! remapped to per-face attributes so the face-rendering path
7//! handles colouring without any new GPU infrastructure.
8//!
9//! # Cell conventions
10//!
11//! Every cell is stored as exactly **8 vertex indices** using [`CELL_SENTINEL`]
12//! (`u32::MAX`) to pad unused slots:
13//! - **Tet**: indices `[0..4]` valid; `[4..8]` = `CELL_SENTINEL`
14//! - **Pyramid**: indices `[0..5]` valid; `[5..8]` = `CELL_SENTINEL`
15//! - **Wedge**: indices `[0..6]` valid; `[6..8]` = `CELL_SENTINEL`
16//! - **Hex**: all 8 indices are valid vertex positions.
17//!
18//! Mixed meshes use the sentinel convention to distinguish cell type per cell.
19//!
20//! Hex face winding follows the standard VTK unstructured-grid ordering so that
21//! outward normals are consistent when all cells have positive volume.
22
23use std::collections::HashMap;
24
25use rayon::prelude::*;
26
27use super::types::{AttributeData, MeshData};
28
29const PARALLEL_THRESHOLD: usize = 1024;
30
31/// Sentinel value that marks unused index slots in a cell stored as 8 indices.
32///
33/// Slots beyond the cell's vertex count must be filled with this value.
34/// For example, a tet uses slots `[0..4]`; slots `[4..8]` must be `CELL_SENTINEL`.
35pub const CELL_SENTINEL: u32 = u32::MAX;
36
37/// Deprecated alias for [`CELL_SENTINEL`].
38#[deprecated(since = "0.13.0", note = "use `CELL_SENTINEL` instead")]
39pub const TET_SENTINEL: u32 = CELL_SENTINEL;
40
41/// Input data for an unstructured volume mesh (tets, hexes, or mixed).
42///
43/// Each cell is represented as exactly 8 vertex indices.  For cells with fewer
44/// than 8 vertices, fill unused slots with [`CELL_SENTINEL`] (`u32::MAX`).
45///
46/// ```
47/// use viewport_lib::{VolumeMeshData, CELL_SENTINEL};
48///
49/// // Two tets sharing vertices 0-1-2
50/// let mut data = VolumeMeshData::default();
51/// data.positions = vec![
52///     [0.0, 0.0, 0.0],
53///     [1.0, 0.0, 0.0],
54///     [0.5, 1.0, 0.0],
55///     [0.5, 0.5, 1.0],
56///     [0.5, 0.5, -1.0],
57/// ];
58/// data.cells = vec![
59///     [0, 1, 2, 3, CELL_SENTINEL, CELL_SENTINEL, CELL_SENTINEL, CELL_SENTINEL],
60///     [0, 2, 1, 4, CELL_SENTINEL, CELL_SENTINEL, CELL_SENTINEL, CELL_SENTINEL],
61/// ];
62/// ```
63#[non_exhaustive]
64#[derive(Default, Clone)]
65pub struct VolumeMeshData {
66    /// Vertex positions in local space.
67    pub positions: Vec<[f32; 3]>,
68
69    /// Cell connectivity : exactly 8 indices per cell.
70    ///
71    /// Tets: first 4 indices are the tet vertices; indices `[4..8]` must be
72    /// [`CELL_SENTINEL`].  Hexes: all 8 indices are valid.  Other cell types
73    /// use [`CELL_SENTINEL`] to pad unused slots (see module-level docs).
74    pub cells: Vec<[u32; 8]>,
75
76    /// Named per-cell scalar attributes (one `f32` per cell).
77    ///
78    /// Automatically remapped to boundary face scalars during upload so they
79    /// can be visualised via [`AttributeKind::Face`](super::types::AttributeKind::Face).
80    pub cell_scalars: HashMap<String, Vec<f32>>,
81
82    /// Named per-cell RGBA colour attributes (one `[f32; 4]` per cell).
83    ///
84    /// Automatically remapped to boundary face colours during upload, rendered
85    /// via [`AttributeKind::FaceColour`](super::types::AttributeKind::FaceColour).
86    pub cell_colours: HashMap<String, Vec<[f32; 4]>>,
87}
88
89// ---------------------------------------------------------------------------
90// Tet face table
91// ---------------------------------------------------------------------------
92//
93// One face per vertex of the tet (face is opposite that vertex).
94// The winding listed here may be inward or outward depending on the tet's
95// signed volume; the geometric winding-correction step in
96// `extract_boundary_faces` normalises every boundary face to outward after
97// extraction, so the exact winding here does not matter for correctness.
98// We just need a consistent convention so the sorted-key boundary detection
99// works (both cells that share an interior face must produce the same key).
100
101const TET_FACES: [[usize; 3]; 4] = [
102    [1, 2, 3], // opposite v0
103    [0, 3, 2], // opposite v1
104    [0, 1, 3], // opposite v2
105    [0, 2, 1], // opposite v3
106];
107
108// ---------------------------------------------------------------------------
109// Hex face table
110// ---------------------------------------------------------------------------
111//
112// VTK hex vertex numbering used in `upload_volume_mesh_data` docs:
113//
114//     7 --- 6          top face
115//    /|    /|
116//   4 --- 5 |
117//   | 3 --| 2          bottom face
118//   |/    |/
119//   0 --- 1
120//
121// Six quad faces.  Verified to produce outward normals (from-cell CCW):
122//
123//   bottom (-Y): [0,1,2,3]  : normal = (1,0,0)x(1,0,1) = (0,-1,0) ok
124//   top    (+Y): [4,7,6,5]  : normal = (0,0,1)x(1,0,1) = (0,+1,0) ok
125//   front  (-Z): [0,4,5,1]  : normal = (0,1,0)x(1,1,0) = (0,0,-1) ok
126//   back   (+Z): [2,6,7,3]  : normal = (0,1,0)x(-1,1,0)= (0,0,+1) ok
127//   left   (-X): [0,3,7,4]  : normal = (0,0,1)x(0,1,1) = (-1,0,0) ok
128//   right  (+X): [1,5,6,2]  : normal = (0,1,0)x(0,1,1) = (+1,0,0) ok
129//
130// The geometric winding-correction step acts as a safety net in case any
131// cell is degenerate or oriented unexpectedly.
132
133const HEX_FACES: [[usize; 4]; 6] = [
134    [0, 1, 2, 3], // bottom (-Y)
135    [4, 7, 6, 5], // top    (+Y)
136    [0, 4, 5, 1], // front  (-Z)
137    [2, 6, 7, 3], // back   (+Z)
138    [0, 3, 7, 4], // left   (-X)
139    [1, 5, 6, 2], // right  (+X)
140];
141
142/// Opposite face index for each entry in [`HEX_FACES`].
143const HEX_FACE_OPPOSITE: [usize; 6] = [1, 0, 3, 2, 5, 4];
144
145// ---------------------------------------------------------------------------
146// Pyramid face tables
147// ---------------------------------------------------------------------------
148//
149// VTK pyramid vertex numbering:
150//
151//        4 (apex)
152//       /|\
153//      / | \
154//     /  |  \
155//    3---+---2
156//    |       |
157//    0-------1
158//
159// One quad base face and four triangular side faces.
160// Winding correction in the extractor normalises outward direction.
161
162/// Quad base face of a pyramid (vertices 0-3).
163const PYRAMID_QUAD_FACE: [[usize; 4]; 1] = [
164    [0, 1, 2, 3], // base
165];
166
167/// Triangular side faces of a pyramid (apex = vertex 4).
168const PYRAMID_TRI_FACES: [[usize; 3]; 4] = [
169    [0, 4, 1], // front
170    [1, 4, 2], // right
171    [2, 4, 3], // back
172    [3, 4, 0], // left
173];
174
175/// Edges of a pyramid: 4 base + 4 lateral.
176const PYRAMID_EDGES: [[usize; 2]; 8] = [
177    [0, 1],
178    [1, 2],
179    [2, 3],
180    [3, 0], // base ring
181    [0, 4],
182    [1, 4],
183    [2, 4],
184    [3, 4], // lateral
185];
186
187// ---------------------------------------------------------------------------
188// Wedge (triangular prism) face tables
189// ---------------------------------------------------------------------------
190//
191// VTK wedge vertex numbering: 0,1,2 = bottom tri, 3,4,5 = top tri
192// (vertex 3 is directly above vertex 0, etc.)
193//
194//   3 --- 5
195//   |  \  |
196//   |   4 |
197//   |     |
198//   0 --- 2
199//    \   /
200//      1
201//
202// Two triangular end faces and three quad lateral faces.
203
204/// Triangular end faces of a wedge.
205const WEDGE_TRI_FACES: [[usize; 3]; 2] = [
206    [0, 2, 1], // bottom (outward = downward)
207    [3, 4, 5], // top    (outward = upward)
208];
209
210/// Quad lateral faces of a wedge.
211const WEDGE_QUAD_FACES: [[usize; 4]; 3] = [
212    [0, 1, 4, 3], // side 0
213    [1, 2, 5, 4], // side 1
214    [2, 0, 3, 5], // side 2
215];
216
217/// Edges of a wedge: 3 bottom + 3 top + 3 vertical.
218const WEDGE_EDGES: [[usize; 2]; 9] = [
219    [0, 1],
220    [1, 2],
221    [2, 0], // bottom tri
222    [3, 4],
223    [4, 5],
224    [5, 3], // top tri
225    [0, 3],
226    [1, 4],
227    [2, 5], // vertical
228];
229
230// ---------------------------------------------------------------------------
231// Boundary extraction
232// ---------------------------------------------------------------------------
233
234/// A canonical (sorted) face key used for boundary detection.
235type FaceKey = (u32, u32, u32);
236
237/// Canonical key for a quad face, sorted by vertex index.
238type QuadFaceKey = (u32, u32, u32, u32);
239
240// (sorted_key, cell_idx, winding, interior_ref)
241type TriEntry = (FaceKey, usize, [u32; 3], [f32; 3]);
242type QuadEntry = (QuadFaceKey, usize, [u32; 4], [f32; 3]);
243
244/// Build a sorted key from three vertex indices.
245#[inline]
246fn face_key(a: u32, b: u32, c: u32) -> FaceKey {
247    let mut arr = [a, b, c];
248    arr.sort_unstable();
249    (arr[0], arr[1], arr[2])
250}
251
252/// Build a sorted key from four vertex indices.
253#[inline]
254fn quad_face_key(a: u32, b: u32, c: u32, d: u32) -> QuadFaceKey {
255    let mut arr = [a, b, c, d];
256    arr.sort_unstable();
257    (arr[0], arr[1], arr[2], arr[3])
258}
259
260/// Generate all triangular face entries for a single cell.
261fn generate_tri_entries(cell_idx: usize, cell: &[u32; 8], positions: &[[f32; 3]]) -> Vec<TriEntry> {
262    let ct = cell_type(cell);
263    let nv = ct.vertex_count();
264    let mut out = Vec::new();
265    match ct {
266        CellType::Tet => {
267            for (face_idx, face_local) in TET_FACES.iter().enumerate() {
268                let a = cell[face_local[0]];
269                let b = cell[face_local[1]];
270                let c = cell[face_local[2]];
271                // Opposite vertex is the best interior reference for tets.
272                let interior_ref = positions[cell[face_idx] as usize];
273                out.push((face_key(a, b, c), cell_idx, [a, b, c], interior_ref));
274            }
275        }
276        CellType::Pyramid => {
277            let centroid = cell_centroid(cell, nv, positions);
278            for face_local in &PYRAMID_TRI_FACES {
279                let a = cell[face_local[0]];
280                let b = cell[face_local[1]];
281                let c = cell[face_local[2]];
282                out.push((face_key(a, b, c), cell_idx, [a, b, c], centroid));
283            }
284        }
285        CellType::Wedge => {
286            let centroid = cell_centroid(cell, nv, positions);
287            for face_local in &WEDGE_TRI_FACES {
288                let a = cell[face_local[0]];
289                let b = cell[face_local[1]];
290                let c = cell[face_local[2]];
291                out.push((face_key(a, b, c), cell_idx, [a, b, c], centroid));
292            }
293        }
294        CellType::Hex => {} // hex has no triangular faces
295    }
296    out
297}
298
299/// Generate all quad face entries for a single cell.
300fn generate_quad_entries(
301    cell_idx: usize,
302    cell: &[u32; 8],
303    positions: &[[f32; 3]],
304) -> Vec<QuadEntry> {
305    let ct = cell_type(cell);
306    let nv = ct.vertex_count();
307    let mut out = Vec::new();
308    match ct {
309        CellType::Tet => {} // tet has no quad faces
310        CellType::Pyramid => {
311            let centroid = cell_centroid(cell, nv, positions);
312            for quad_local in &PYRAMID_QUAD_FACE {
313                let v = [
314                    cell[quad_local[0]],
315                    cell[quad_local[1]],
316                    cell[quad_local[2]],
317                    cell[quad_local[3]],
318                ];
319                out.push((quad_face_key(v[0], v[1], v[2], v[3]), cell_idx, v, centroid));
320            }
321        }
322        CellType::Wedge => {
323            let centroid = cell_centroid(cell, nv, positions);
324            for quad_local in &WEDGE_QUAD_FACES {
325                let v = [
326                    cell[quad_local[0]],
327                    cell[quad_local[1]],
328                    cell[quad_local[2]],
329                    cell[quad_local[3]],
330                ];
331                out.push((quad_face_key(v[0], v[1], v[2], v[3]), cell_idx, v, centroid));
332            }
333        }
334        CellType::Hex => {
335            for (face_idx, quad) in HEX_FACES.iter().enumerate() {
336                let v: [u32; 4] = [cell[quad[0]], cell[quad[1]], cell[quad[2]], cell[quad[3]]];
337                let interior_ref = {
338                    let opposite = &HEX_FACES[HEX_FACE_OPPOSITE[face_idx]];
339                    let mut c = [0.0f32; 3];
340                    for &local_vi in opposite {
341                        let p = positions[cell[local_vi] as usize];
342                        c[0] += p[0];
343                        c[1] += p[1];
344                        c[2] += p[2];
345                    }
346                    [c[0] / 4.0, c[1] / 4.0, c[2] / 4.0]
347                };
348                out.push((
349                    quad_face_key(v[0], v[1], v[2], v[3]),
350                    cell_idx,
351                    v,
352                    interior_ref,
353                ));
354            }
355        }
356    }
357    out
358}
359
360/// Collect entries that appear exactly once (boundary faces) from a sorted slice.
361fn collect_boundary_tri(entries: &[TriEntry]) -> Vec<(usize, [u32; 3], [f32; 3])> {
362    let mut out = Vec::new();
363    let mut i = 0;
364    while i < entries.len() {
365        let key = entries[i].0;
366        let mut j = i + 1;
367        while j < entries.len() && entries[j].0 == key {
368            j += 1;
369        }
370        if j - i == 1 {
371            out.push((entries[i].1, entries[i].2, entries[i].3));
372        }
373        i = j;
374    }
375    out
376}
377
378/// Collect quad entries that appear exactly once (boundary faces) from a sorted slice.
379fn collect_boundary_quad(entries: &[QuadEntry]) -> Vec<(usize, [u32; 4], [f32; 3])> {
380    let mut out = Vec::new();
381    let mut i = 0;
382    while i < entries.len() {
383        let key = entries[i].0;
384        let mut j = i + 1;
385        while j < entries.len() && entries[j].0 == key {
386            j += 1;
387        }
388        if j - i == 1 {
389            out.push((entries[i].1, entries[i].2, entries[i].3));
390        }
391        i = j;
392    }
393    out
394}
395
396/// Ensure the triangle winding produces an outward-facing normal relative to
397/// `interior_ref` (a point inside the owning cell).
398#[inline]
399fn correct_winding(tri: &mut [u32; 3], interior_ref: &[f32; 3], positions: &[[f32; 3]]) {
400    let pa = positions[tri[0] as usize];
401    let pb = positions[tri[1] as usize];
402    let pc = positions[tri[2] as usize];
403    let ab = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
404    let ac = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
405    let normal = [
406        ab[1] * ac[2] - ab[2] * ac[1],
407        ab[2] * ac[0] - ab[0] * ac[2],
408        ab[0] * ac[1] - ab[1] * ac[0],
409    ];
410    let fc = [
411        (pa[0] + pb[0] + pc[0]) / 3.0,
412        (pa[1] + pb[1] + pc[1]) / 3.0,
413        (pa[2] + pb[2] + pc[2]) / 3.0,
414    ];
415    let out = [
416        fc[0] - interior_ref[0],
417        fc[1] - interior_ref[1],
418        fc[2] - interior_ref[2],
419    ];
420    if normal[0] * out[0] + normal[1] * out[1] + normal[2] * out[2] < 0.0 {
421        tri.swap(1, 2);
422    }
423}
424
425/// Convert [`VolumeMeshData`] into a standard [`MeshData`] by extracting the
426/// boundary surface and remapping per-cell attributes to per-face attributes.
427///
428/// After this step the boundary mesh is uploaded
429/// via [`upload_mesh_data`](super::ViewportGpuResources::upload_mesh_data)
430/// and rendered exactly like any other surface mesh.
431///
432/// Returns `(mesh_data, face_to_cell)` where `face_to_cell[i]` is the cell
433/// index that boundary triangle `i` belongs to.
434pub(crate) fn extract_boundary_faces(data: &VolumeMeshData) -> (MeshData, Vec<u32>) {
435    let n_verts = data.positions.len();
436
437    // Generate face entries (parallel above threshold, sequential below).
438    let (mut tri_entries, mut quad_entries) = if data.cells.len() >= PARALLEL_THRESHOLD {
439        let tri = data
440            .cells
441            .par_iter()
442            .enumerate()
443            .flat_map_iter(|(ci, cell)| generate_tri_entries(ci, cell, &data.positions))
444            .collect();
445        let quad = data
446            .cells
447            .par_iter()
448            .enumerate()
449            .flat_map_iter(|(ci, cell)| generate_quad_entries(ci, cell, &data.positions))
450            .collect();
451        (tri, quad)
452    } else {
453        let mut tri: Vec<TriEntry> = Vec::new();
454        let mut quad: Vec<QuadEntry> = Vec::new();
455        for (ci, cell) in data.cells.iter().enumerate() {
456            tri.extend(generate_tri_entries(ci, cell, &data.positions));
457            quad.extend(generate_quad_entries(ci, cell, &data.positions));
458        }
459        (tri, quad)
460    };
461
462    tri_entries.par_sort_unstable_by_key(|e| e.0);
463    quad_entries.par_sort_unstable_by_key(|e| e.0);
464
465    // Collect boundary faces (count == 1) via linear scan.
466    let mut boundary: Vec<(usize, [u32; 3], [f32; 3])> = collect_boundary_tri(&tri_entries);
467    for (ci, winding, iref) in collect_boundary_quad(&quad_entries) {
468        boundary.push((ci, [winding[0], winding[1], winding[2]], iref));
469        boundary.push((ci, [winding[0], winding[2], winding[3]], iref));
470    }
471
472    // Sort by cell index for deterministic output (useful for testing).
473    boundary.sort_unstable_by_key(|(ci, _, _)| *ci);
474
475    // Geometric winding correction (parallel): ensure each boundary face's normal
476    // points outward. This is the primary correctness mechanism for tets where
477    // the table winding may be inward.
478    boundary
479        .par_iter_mut()
480        .for_each(|(_, tri, iref)| correct_winding(tri, iref, &data.positions));
481
482    let n_boundary_tris = boundary.len();
483
484    // Build index buffer and accumulate area-weighted normals (sequential:
485    // normal_accum has shared per-vertex writes).
486    let mut indices: Vec<u32> = Vec::with_capacity(n_boundary_tris * 3);
487    let mut normal_accum: Vec<[f64; 3]> = vec![[0.0; 3]; n_verts];
488
489    for (_, tri, _) in &boundary {
490        indices.push(tri[0]);
491        indices.push(tri[1]);
492        indices.push(tri[2]);
493
494        let pa = data.positions[tri[0] as usize];
495        let pb = data.positions[tri[1] as usize];
496        let pc = data.positions[tri[2] as usize];
497        let ab = [
498            (pb[0] - pa[0]) as f64,
499            (pb[1] - pa[1]) as f64,
500            (pb[2] - pa[2]) as f64,
501        ];
502        let ac = [
503            (pc[0] - pa[0]) as f64,
504            (pc[1] - pa[1]) as f64,
505            (pc[2] - pa[2]) as f64,
506        ];
507        let n = [
508            ab[1] * ac[2] - ab[2] * ac[1],
509            ab[2] * ac[0] - ab[0] * ac[2],
510            ab[0] * ac[1] - ab[1] * ac[0],
511        ];
512        for &vi in tri {
513            let acc = &mut normal_accum[vi as usize];
514            acc[0] += n[0];
515            acc[1] += n[1];
516            acc[2] += n[2];
517        }
518    }
519
520    let mut normals: Vec<[f32; 3]> = normal_accum
521        .iter()
522        .map(|n| {
523            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
524            if len > 1e-12 {
525                [
526                    (n[0] / len) as f32,
527                    (n[1] / len) as f32,
528                    (n[2] / len) as f32,
529                ]
530            } else {
531                [0.0, 1.0, 0.0]
532            }
533        })
534        .collect();
535
536    normals.resize(n_verts, [0.0, 1.0, 0.0]);
537
538    let mut attributes: HashMap<String, AttributeData> = HashMap::new();
539
540    for (name, cell_vals) in &data.cell_scalars {
541        let face_scalars: Vec<f32> = boundary
542            .iter()
543            .map(|(ci, _, _)| cell_vals.get(*ci).copied().unwrap_or(0.0))
544            .collect();
545        attributes.insert(name.clone(), AttributeData::Face(face_scalars));
546    }
547
548    for (name, cell_vals) in &data.cell_colours {
549        let face_colours: Vec<[f32; 4]> = boundary
550            .iter()
551            .map(|(ci, _, _)| cell_vals.get(*ci).copied().unwrap_or([1.0; 4]))
552            .collect();
553        attributes.insert(name.clone(), AttributeData::FaceColour(face_colours));
554    }
555
556    let face_to_cell: Vec<u32> = boundary.iter().map(|(ci, _, _)| *ci as u32).collect();
557
558    (
559        MeshData {
560            positions: data.positions.clone(),
561            normals,
562            indices,
563            uvs: None,
564            tangents: None,
565            attributes,
566        },
567        face_to_cell,
568    )
569}
570
571// ---------------------------------------------------------------------------
572// Clipped volume mesh extraction
573// ---------------------------------------------------------------------------
574//
575// Design note: scope and invariants
576// ==================================
577//
578// ## Goal
579//
580// Produce a `MeshData` that reads as a filled volumetric cross-section rather
581// than an open hollow shell when one or more clip planes intersect a volume mesh.
582//
583// ## What this is NOT
584//
585// This is not a generic clip overlay.  The renderer's cap-fill system generates
586// a flat polygon on each clip plane independently of the underlying geometry.
587// For volume meshes that is wrong: it produces a slab with no per-cell colour
588// information.  `extract_clipped_volume_faces` replaces the cap-fill role for
589// volume meshes entirely.  Callers must disable cap-fill when using this path.
590//
591// ## Clip plane encoding
592//
593// Each plane is `[nx, ny, nz, d]: [f32; 4]` where a point `p` is on the KEPT
594// side when `dot(p, [nx, ny, nz]) + d >= 0`.  This matches the layout of
595// `ClipPlanesUniform::planes` so the same values can be forwarded directly to
596// both the CPU extraction and the GPU clip shader.
597//
598// An empty slice is valid and produces the same result as `extract_boundary_faces`.
599//
600// ## Cell classification
601//
602// A vertex is "kept" if it satisfies ALL planes.
603//
604// - All vertices kept   -> cell contributes its visible boundary faces, unchanged.
605// - No  vertices kept   -> cell is discarded entirely.
606// - Mixed               -> cell is "intersected": contributes clipped boundary
607//                          faces and one section polygon per cutting plane.
608//
609// ## Section polygon semantics
610//
611// For each plane that cuts an intersected cell:
612// 1. Collect all edge-plane intersection points (one per cell edge that crosses
613//    the plane).
614// 2. Order the points into a polygon on the plane (sort by angle around the
615//    centroid projected onto the plane).
616// 3. Clip the polygon against all other active planes.
617// 4. Triangulate the surviving polygon using a fan from the first vertex.
618//
619// Section face winding: the face normal must point in the direction of the
620// cutting plane's normal (i.e., toward the kept side / toward the viewer).
621//
622// ## Boundary face clipping
623//
624// Boundary faces of intersected cells are clipped against all active planes
625// using the Sutherland-Hodgman algorithm before triangulation.  A boundary
626// face entirely on the discarded side of any plane is dropped.
627//
628// ## Attribute propagation
629//
630// Section triangles inherit the owning cell's `cell_scalars` and `cell_colours`
631// values exactly as boundary triangles do.  The output `MeshData` uses the same
632// `AttributeKind::Face` / `AttributeKind::FaceColour` paths, so colourmaps work
633// with no changes to the renderer.
634//
635// ## Output type
636//
637// The function returns an ordinary `MeshData`.  No new intermediate type is
638// introduced.  The caller uploads this as a regular mesh and renders it with
639// the standard pipeline; the only renderer-side requirement is that cap-fill
640// is disabled for the same scene object.
641
642/// Produce a clipped `MeshData` from volume cell connectivity.
643///
644/// Each entry in `clip_planes` is `[nx, ny, nz, d]` where a point `p` is on
645/// the kept side when `dot(p, [nx,ny,nz]) + d >= 0`.  This is the same
646/// encoding as [`ClipPlanesUniform::planes`](crate::renderer::types::ClipPlanesUniform)
647/// so values can be forwarded to both the CPU path and the GPU clip shader.
648///
649/// Passing an empty slice returns the same result as [`extract_boundary_faces`].
650///
651/// # Semantics
652///
653/// - A cell where all vertices satisfy every plane contributes its boundary
654///   faces unchanged.
655/// - A cell where no vertex satisfies every plane is discarded.
656/// - An intersected cell contributes its surviving boundary faces (clipped) and
657///   one section polygon per plane that cuts it (clipped against all other
658///   planes, then triangulated).
659///
660/// Section face normals point toward the kept side (matching the cutting plane
661/// normal).  Per-cell scalar and colour attributes are propagated to section
662/// triangles identically to boundary triangles.
663///
664/// # Renderer contract
665///
666/// Generic cap-fill must be disabled for scene objects rendered via this path.
667/// Section faces are generated here from cell data; the generic cap overlay
668/// does not have access to per-cell attribute information and would produce an
669/// incorrect result if left enabled.
670/// Cell edges for tets: all 6 pairs from 4 vertices.
671const TET_EDGES: [[usize; 2]; 6] = [[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]];
672
673/// Cell edges for hexes (VTK ordering).
674///
675/// ```text
676///     7 --- 6
677///    /|    /|
678///   4 --- 5 |
679///   | 3 --| 2
680///   |/    |/
681///   0 --- 1
682/// ```
683const HEX_EDGES: [[usize; 2]; 12] = [
684    [0, 1],
685    [1, 2],
686    [2, 3],
687    [3, 0], // bottom ring
688    [4, 5],
689    [5, 6],
690    [6, 7],
691    [7, 4], // top ring
692    [0, 4],
693    [1, 5],
694    [2, 6],
695    [3, 7], // vertical
696];
697
698// ---------------------------------------------------------------------------
699// Cell type detection
700// ---------------------------------------------------------------------------
701
702/// Internal cell type, detected from sentinel slots.
703#[derive(Clone, Copy, PartialEq, Eq)]
704enum CellType {
705    Tet,
706    Pyramid,
707    Wedge,
708    Hex,
709}
710
711impl CellType {
712    fn vertex_count(self) -> usize {
713        match self {
714            CellType::Tet => 4,
715            CellType::Pyramid => 5,
716            CellType::Wedge => 6,
717            CellType::Hex => 8,
718        }
719    }
720
721    fn edges(self) -> &'static [[usize; 2]] {
722        match self {
723            CellType::Tet => &TET_EDGES,
724            CellType::Pyramid => &PYRAMID_EDGES,
725            CellType::Wedge => &WEDGE_EDGES,
726            CellType::Hex => &HEX_EDGES,
727        }
728    }
729}
730
731/// Detect cell type from sentinel pattern in the 8-slot cell array.
732#[inline]
733fn cell_type(cell: &[u32; 8]) -> CellType {
734    if cell[4] == CELL_SENTINEL {
735        CellType::Tet
736    } else if cell[5] == CELL_SENTINEL {
737        CellType::Pyramid
738    } else if cell[6] == CELL_SENTINEL {
739        CellType::Wedge
740    } else {
741        CellType::Hex
742    }
743}
744
745/// Centroid of the first `nv` vertices of `cell`.
746#[inline]
747fn cell_centroid(cell: &[u32; 8], nv: usize, positions: &[[f32; 3]]) -> [f32; 3] {
748    let mut c = [0.0f32; 3];
749    for i in 0..nv {
750        let p = positions[cell[i] as usize];
751        c[0] += p[0];
752        c[1] += p[1];
753        c[2] += p[2];
754    }
755    let n = nv as f32;
756    [c[0] / n, c[1] / n, c[2] / n]
757}
758
759/// Signed distance from `p` to `plane` (`[nx, ny, nz, d]`).
760/// Positive means on the kept side (`dot(p, n) + d >= 0`).
761#[inline]
762fn plane_dist(p: [f32; 3], plane: [f32; 4]) -> f32 {
763    p[0] * plane[0] + p[1] * plane[1] + p[2] * plane[2] + plane[3]
764}
765
766/// Cross product of two 3-vectors.
767#[inline]
768fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
769    [
770        a[1] * b[2] - a[2] * b[1],
771        a[2] * b[0] - a[0] * b[2],
772        a[0] * b[1] - a[1] * b[0],
773    ]
774}
775
776/// Dot product of two 3-vectors.
777#[inline]
778fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
779    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
780}
781
782/// Normalize a 3-vector; returns `[0, 1, 0]` for degenerate input.
783#[inline]
784fn normalize3(v: [f32; 3]) -> [f32; 3] {
785    let len = dot3(v, v).sqrt();
786    if len > 1e-12 {
787        [v[0] / len, v[1] / len, v[2] / len]
788    } else {
789        [0.0, 1.0, 0.0]
790    }
791}
792
793/// Intern `p` into `positions`, returning its index.
794/// Uses bit-exact comparison so the same floating-point value always maps to
795/// the same slot.
796fn intern_pos(
797    p: [f32; 3],
798    positions: &mut Vec<[f32; 3]>,
799    pos_map: &mut HashMap<[u32; 3], u32>,
800) -> u32 {
801    let key = [p[0].to_bits(), p[1].to_bits(), p[2].to_bits()];
802    if let Some(&idx) = pos_map.get(&key) {
803        return idx;
804    }
805    let idx = positions.len() as u32;
806    positions.push(p);
807    pos_map.insert(key, idx);
808    idx
809}
810
811/// Clip `poly` against a single plane (Sutherland-Hodgman).
812/// Vertices satisfying `plane_dist >= 0` are on the kept side.
813fn clip_polygon_one_plane(poly: Vec<[f32; 3]>, plane: [f32; 4]) -> Vec<[f32; 3]> {
814    if poly.is_empty() {
815        return poly;
816    }
817    let n = poly.len();
818    let mut out = Vec::with_capacity(n + 1);
819    for i in 0..n {
820        let a = poly[i];
821        let b = poly[(i + 1) % n];
822        let da = plane_dist(a, plane);
823        let db = plane_dist(b, plane);
824        let a_in = da >= 0.0;
825        let b_in = db >= 0.0;
826        if a_in {
827            out.push(a);
828        }
829        if a_in != b_in {
830            let denom = da - db;
831            if denom.abs() > 1e-30 {
832                let t = da / denom;
833                out.push([
834                    a[0] + t * (b[0] - a[0]),
835                    a[1] + t * (b[1] - a[1]),
836                    a[2] + t * (b[2] - a[2]),
837                ]);
838            }
839        }
840    }
841    out
842}
843
844/// Clip `poly` against all `planes` in sequence.
845fn clip_polygon_planes(mut poly: Vec<[f32; 3]>, planes: &[[f32; 4]]) -> Vec<[f32; 3]> {
846    for &plane in planes {
847        if poly.is_empty() {
848            break;
849        }
850        poly = clip_polygon_one_plane(poly, plane);
851    }
852    poly
853}
854
855/// Build an orthonormal `(u, v)` basis for a plane with the given `normal`.
856fn plane_basis(normal: [f32; 3]) -> ([f32; 3], [f32; 3]) {
857    let ref_vec: [f32; 3] = if normal[0].abs() < 0.9 {
858        [1.0, 0.0, 0.0]
859    } else {
860        [0.0, 1.0, 0.0]
861    };
862    let u = normalize3(cross3(normal, ref_vec));
863    let v = cross3(normal, u);
864    (u, v)
865}
866
867/// Sort `pts` into angular order around their centroid on the given plane.
868///
869/// Uses a `(u, v)` frame derived from `normal` so that the resulting polygon
870/// is non-self-intersecting for any convex (and mildly non-convex) cross-section.
871fn sort_polygon_on_plane(pts: &mut Vec<[f32; 3]>, normal: [f32; 3]) {
872    if pts.len() < 3 {
873        return;
874    }
875    let n = pts.len() as f32;
876    let cx = pts.iter().map(|p| p[0]).sum::<f32>() / n;
877    let cy = pts.iter().map(|p| p[1]).sum::<f32>() / n;
878    let cz = pts.iter().map(|p| p[2]).sum::<f32>() / n;
879    let centroid = [cx, cy, cz];
880    let (u, v) = plane_basis(normal);
881    pts.sort_by(|a, b| {
882        let da = [a[0] - centroid[0], a[1] - centroid[1], a[2] - centroid[2]];
883        let db = [b[0] - centroid[0], b[1] - centroid[1], b[2] - centroid[2]];
884        let ang_a = dot3(da, v).atan2(dot3(da, u));
885        let ang_b = dot3(db, v).atan2(dot3(db, u));
886        ang_a
887            .partial_cmp(&ang_b)
888            .unwrap_or(std::cmp::Ordering::Equal)
889    });
890}
891
892/// Fan-triangulate a polygon from `poly[0]`.
893fn fan_triangulate(poly: &[[f32; 3]]) -> Vec<[[f32; 3]; 3]> {
894    if poly.len() < 3 {
895        return Vec::new();
896    }
897    (1..poly.len() - 1)
898        .map(|i| [poly[0], poly[i], poly[i + 1]])
899        .collect()
900}
901
902/// Generate section triangles for a single intersected cell across all clip planes.
903fn generate_section_tris(
904    cell_idx: usize,
905    cell: &[u32; 8],
906    positions: &[[f32; 3]],
907    clip_planes: &[[f32; 4]],
908) -> Vec<(usize, [[f32; 3]; 3])> {
909    let mut out = Vec::new();
910    let edges = cell_type(cell).edges();
911
912    for (pi, &plane) in clip_planes.iter().enumerate() {
913        let mut pts: Vec<[f32; 3]> = Vec::new();
914        for edge in edges {
915            let pa = positions[cell[edge[0]] as usize];
916            let pb = positions[cell[edge[1]] as usize];
917            let da = plane_dist(pa, plane);
918            let db = plane_dist(pb, plane);
919            if (da >= 0.0) != (db >= 0.0) {
920                let denom = da - db;
921                if denom.abs() > 1e-30 {
922                    let t = da / denom;
923                    pts.push([
924                        pa[0] + t * (pb[0] - pa[0]),
925                        pa[1] + t * (pb[1] - pa[1]),
926                        pa[2] + t * (pb[2] - pa[2]),
927                    ]);
928                }
929            }
930        }
931        if pts.len() < 3 {
932            continue;
933        }
934        let plane_normal = [plane[0], plane[1], plane[2]];
935        sort_polygon_on_plane(&mut pts, plane_normal);
936        let other_planes: Vec<[f32; 4]> = clip_planes
937            .iter()
938            .enumerate()
939            .filter(|(i, _)| *i != pi)
940            .map(|(_, p)| *p)
941            .collect();
942        let pts = clip_polygon_planes(pts, &other_planes);
943        if pts.len() < 3 {
944            continue;
945        }
946        for mut tri in fan_triangulate(&pts) {
947            let ab = [
948                tri[1][0] - tri[0][0],
949                tri[1][1] - tri[0][1],
950                tri[1][2] - tri[0][2],
951            ];
952            let ac = [
953                tri[2][0] - tri[0][0],
954                tri[2][1] - tri[0][1],
955                tri[2][2] - tri[0][2],
956            ];
957            let n = cross3(ab, ac);
958            if dot3(n, plane_normal) < 0.0 {
959                tri.swap(1, 2);
960            }
961            out.push((cell_idx, tri));
962        }
963    }
964    out
965}
966
967/// Produce a clipped `MeshData` from volume cell connectivity and a set of
968/// clip planes.
969///
970/// See the design note in the section comment above for the full contract.
971/// Extract boundary and section faces from a volume mesh clipped by one or
972/// more planes.
973///
974/// Returns `(mesh_data, face_to_cell)` where `face_to_cell[i]` is the cell
975/// index that output triangle `i` belongs to.
976pub fn extract_clipped_volume_faces(
977    data: &VolumeMeshData,
978    clip_planes: &[[f32; 4]],
979) -> (MeshData, Vec<u32>) {
980    if clip_planes.is_empty() {
981        return extract_boundary_faces(data);
982    }
983
984    // Classify every vertex: kept = satisfies ALL planes (parallel).
985    let vert_kept: Vec<bool> = data
986        .positions
987        .par_iter()
988        .map(|&p| clip_planes.iter().all(|&pl| plane_dist(p, pl) >= 0.0))
989        .collect();
990
991    // Generate face entries, skipping fully-discarded cells.
992    let (mut tri_entries, mut quad_entries) = if data.cells.len() >= PARALLEL_THRESHOLD {
993        let vk = &vert_kept;
994        let tri = data
995            .cells
996            .par_iter()
997            .enumerate()
998            .flat_map_iter(|(ci, cell)| {
999                let nv = cell_type(cell).vertex_count();
1000                if (0..nv).all(|i| !vk[cell[i] as usize]) {
1001                    return Vec::new();
1002                }
1003                generate_tri_entries(ci, cell, &data.positions)
1004            })
1005            .collect();
1006        let quad = data
1007            .cells
1008            .par_iter()
1009            .enumerate()
1010            .flat_map_iter(|(ci, cell)| {
1011                let nv = cell_type(cell).vertex_count();
1012                if (0..nv).all(|i| !vk[cell[i] as usize]) {
1013                    return Vec::new();
1014                }
1015                generate_quad_entries(ci, cell, &data.positions)
1016            })
1017            .collect();
1018        (tri, quad)
1019    } else {
1020        let mut tri: Vec<TriEntry> = Vec::new();
1021        let mut quad: Vec<QuadEntry> = Vec::new();
1022        for (ci, cell) in data.cells.iter().enumerate() {
1023            let nv = cell_type(cell).vertex_count();
1024            let kc = (0..nv).filter(|&i| vert_kept[cell[i] as usize]).count();
1025            if kc == 0 {
1026                continue;
1027            }
1028            tri.extend(generate_tri_entries(ci, cell, &data.positions));
1029            quad.extend(generate_quad_entries(ci, cell, &data.positions));
1030        }
1031        (tri, quad)
1032    };
1033
1034    tri_entries.par_sort_unstable_by_key(|e| e.0);
1035    quad_entries.par_sort_unstable_by_key(|e| e.0);
1036
1037    let mut boundary: Vec<(usize, [u32; 3], [f32; 3])> = collect_boundary_tri(&tri_entries);
1038    for (ci, winding, iref) in collect_boundary_quad(&quad_entries) {
1039        boundary.push((ci, [winding[0], winding[1], winding[2]], iref));
1040        boundary.push((ci, [winding[0], winding[2], winding[3]], iref));
1041    }
1042    boundary.sort_unstable_by_key(|(ci, _, _)| *ci);
1043
1044    boundary
1045        .par_iter_mut()
1046        .for_each(|(_, tri, iref)| correct_winding(tri, iref, &data.positions));
1047
1048    // Precompute per-cell vertex and kept-vertex counts.
1049    let cell_nv: Vec<usize> = data
1050        .cells
1051        .iter()
1052        .map(|c| cell_type(c).vertex_count())
1053        .collect();
1054    let cell_kept: Vec<usize> = data
1055        .cells
1056        .iter()
1057        .zip(cell_nv.iter())
1058        .map(|(cell, &nv)| (0..nv).filter(|&i| vert_kept[cell[i] as usize]).count())
1059        .collect();
1060
1061    // Boundary faces: emit directly for fully-kept cells, clip for intersected (parallel).
1062    let mut out_tris: Vec<(usize, [[f32; 3]; 3])> = boundary
1063        .par_iter()
1064        .flat_map_iter(|(cell_idx, tri, _)| {
1065            let nv = cell_nv[*cell_idx];
1066            let kc = cell_kept[*cell_idx];
1067            let pa = data.positions[tri[0] as usize];
1068            let pb = data.positions[tri[1] as usize];
1069            let pc = data.positions[tri[2] as usize];
1070            if kc == nv {
1071                vec![(*cell_idx, [pa, pb, pc])]
1072            } else {
1073                let clipped = clip_polygon_planes(vec![pa, pb, pc], clip_planes);
1074                fan_triangulate(&clipped)
1075                    .into_iter()
1076                    .map(|t| (*cell_idx, t))
1077                    .collect()
1078            }
1079        })
1080        .collect();
1081
1082    // Section polygons: one per cutting plane per intersected cell (parallel).
1083    let section_tris: Vec<(usize, [[f32; 3]; 3])> = data
1084        .cells
1085        .par_iter()
1086        .enumerate()
1087        .filter(|(ci, _)| {
1088            let kc = cell_kept[*ci];
1089            kc > 0 && kc < cell_nv[*ci]
1090        })
1091        .flat_map_iter(|(ci, cell)| generate_section_tris(ci, cell, &data.positions, clip_planes))
1092        .collect();
1093    out_tris.extend(section_tris);
1094
1095    // Intern positions and build the index buffer (sequential: shared HashMap).
1096    let mut positions: Vec<[f32; 3]> = data.positions.clone();
1097    let mut pos_map: HashMap<[u32; 3], u32> = HashMap::new();
1098    for (i, &p) in data.positions.iter().enumerate() {
1099        let key = [p[0].to_bits(), p[1].to_bits(), p[2].to_bits()];
1100        pos_map.entry(key).or_insert(i as u32);
1101    }
1102
1103    let mut indexed_tris: Vec<(usize, [u32; 3])> = Vec::with_capacity(out_tris.len());
1104    for (cell_idx, [p0, p1, p2]) in &out_tris {
1105        let i0 = intern_pos(*p0, &mut positions, &mut pos_map);
1106        let i1 = intern_pos(*p1, &mut positions, &mut pos_map);
1107        let i2 = intern_pos(*p2, &mut positions, &mut pos_map);
1108        indexed_tris.push((*cell_idx, [i0, i1, i2]));
1109    }
1110
1111    let n_verts = positions.len();
1112    let mut normal_accum: Vec<[f64; 3]> = vec![[0.0; 3]; n_verts];
1113    let mut indices: Vec<u32> = Vec::with_capacity(indexed_tris.len() * 3);
1114
1115    for (_, tri) in &indexed_tris {
1116        indices.push(tri[0]);
1117        indices.push(tri[1]);
1118        indices.push(tri[2]);
1119
1120        let pa = positions[tri[0] as usize];
1121        let pb = positions[tri[1] as usize];
1122        let pc = positions[tri[2] as usize];
1123        let ab = [
1124            (pb[0] - pa[0]) as f64,
1125            (pb[1] - pa[1]) as f64,
1126            (pb[2] - pa[2]) as f64,
1127        ];
1128        let ac = [
1129            (pc[0] - pa[0]) as f64,
1130            (pc[1] - pa[1]) as f64,
1131            (pc[2] - pa[2]) as f64,
1132        ];
1133        let n = [
1134            ab[1] * ac[2] - ab[2] * ac[1],
1135            ab[2] * ac[0] - ab[0] * ac[2],
1136            ab[0] * ac[1] - ab[1] * ac[0],
1137        ];
1138        for &vi in tri {
1139            let acc = &mut normal_accum[vi as usize];
1140            acc[0] += n[0];
1141            acc[1] += n[1];
1142            acc[2] += n[2];
1143        }
1144    }
1145
1146    let normals: Vec<[f32; 3]> = normal_accum
1147        .iter()
1148        .map(|n| {
1149            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1150            if len > 1e-12 {
1151                [
1152                    (n[0] / len) as f32,
1153                    (n[1] / len) as f32,
1154                    (n[2] / len) as f32,
1155                ]
1156            } else {
1157                [0.0, 1.0, 0.0]
1158            }
1159        })
1160        .collect();
1161
1162    let mut attributes: HashMap<String, AttributeData> = HashMap::new();
1163    for (name, cell_vals) in &data.cell_scalars {
1164        let face_scalars: Vec<f32> = indexed_tris
1165            .iter()
1166            .map(|(ci, _)| cell_vals.get(*ci).copied().unwrap_or(0.0))
1167            .collect();
1168        attributes.insert(name.clone(), AttributeData::Face(face_scalars));
1169    }
1170    for (name, cell_vals) in &data.cell_colours {
1171        let face_colours: Vec<[f32; 4]> = indexed_tris
1172            .iter()
1173            .map(|(ci, _)| cell_vals.get(*ci).copied().unwrap_or([1.0; 4]))
1174            .collect();
1175        attributes.insert(name.clone(), AttributeData::FaceColour(face_colours));
1176    }
1177
1178    let face_to_cell: Vec<u32> = indexed_tris.iter().map(|(ci, _)| *ci as u32).collect();
1179
1180    (
1181        MeshData {
1182            positions,
1183            normals,
1184            indices,
1185            uvs: None,
1186            tangents: None,
1187            attributes,
1188        },
1189        face_to_cell,
1190    )
1191}
1192
1193// ---------------------------------------------------------------------------
1194// VolumeMeshData helpers
1195// ---------------------------------------------------------------------------
1196
1197impl VolumeMeshData {
1198    /// Append a tetrahedral cell (4 vertices).
1199    ///
1200    /// Slots `[4..8]` are filled with [`CELL_SENTINEL`] automatically.
1201    pub fn push_tet(&mut self, a: u32, b: u32, c: u32, d: u32) {
1202        self.cells.push([
1203            a,
1204            b,
1205            c,
1206            d,
1207            CELL_SENTINEL,
1208            CELL_SENTINEL,
1209            CELL_SENTINEL,
1210            CELL_SENTINEL,
1211        ]);
1212    }
1213
1214    /// Append a pyramidal cell (square base + apex, 5 vertices).
1215    ///
1216    /// `base` holds the four base vertices in VTK order (counter-clockwise
1217    /// when viewed from outside the cell); `apex` is the tip vertex.
1218    /// Slots `[5..8]` are filled with [`CELL_SENTINEL`] automatically.
1219    pub fn push_pyramid(&mut self, base: [u32; 4], apex: u32) {
1220        self.cells.push([
1221            base[0],
1222            base[1],
1223            base[2],
1224            base[3],
1225            apex,
1226            CELL_SENTINEL,
1227            CELL_SENTINEL,
1228            CELL_SENTINEL,
1229        ]);
1230    }
1231
1232    /// Append a wedge (triangular prism) cell (6 vertices).
1233    ///
1234    /// `tri0` and `tri1` are the bottom and top triangular faces; vertex
1235    /// `tri1[i]` is directly above `tri0[i]`, forming the three lateral quad
1236    /// faces.  Slots `[6..8]` are filled with [`CELL_SENTINEL`] automatically.
1237    pub fn push_wedge(&mut self, tri0: [u32; 3], tri1: [u32; 3]) {
1238        self.cells.push([
1239            tri0[0],
1240            tri0[1],
1241            tri0[2],
1242            tri1[0],
1243            tri1[1],
1244            tri1[2],
1245            CELL_SENTINEL,
1246            CELL_SENTINEL,
1247        ]);
1248    }
1249
1250    /// Append a hexahedral cell (8 vertices, VTK ordering).
1251    pub fn push_hex(&mut self, verts: [u32; 8]) {
1252        self.cells.push(verts);
1253    }
1254
1255    /// Extract all tetrahedral cells and return a [`TetMesh`].
1256    ///
1257    /// Cells whose slots `[4..8]` are all [`CELL_SENTINEL`] are tets; every
1258    /// other cell shape is dropped and counted in the returned
1259    /// [`ConversionReport`]. Returns an error if no tet cells are present or
1260    /// if a cell references a vertex index out of range.
1261    pub fn to_tet_mesh(
1262        &self,
1263    ) -> Result<(super::tetmesh::TetMesh, ConversionReport), ToTetMeshError> {
1264        use glam::Vec3;
1265
1266        let mut tet_indices: Vec<[u32; 4]> = Vec::new();
1267        let mut dropped = 0usize;
1268        for cell in &self.cells {
1269            if cell[4] == CELL_SENTINEL
1270                && cell[5] == CELL_SENTINEL
1271                && cell[6] == CELL_SENTINEL
1272                && cell[7] == CELL_SENTINEL
1273            {
1274                tet_indices.push([cell[0], cell[1], cell[2], cell[3]]);
1275            } else {
1276                dropped += 1;
1277            }
1278        }
1279
1280        if tet_indices.is_empty() {
1281            return Err(ToTetMeshError::NoTetCells);
1282        }
1283
1284        let vertex_count = self.positions.len();
1285        let mut remap = vec![u32::MAX; vertex_count];
1286        let mut positions: Vec<Vec3> = Vec::new();
1287        let mut tets: Vec<[u32; 4]> = Vec::with_capacity(tet_indices.len());
1288        for raw in tet_indices {
1289            let mut out = [0_u32; 4];
1290            for (slot, &idx) in raw.iter().enumerate() {
1291                let idx_usize = idx as usize;
1292                if idx_usize >= vertex_count {
1293                    return Err(ToTetMeshError::OutOfRangeIndex(idx));
1294                }
1295                if remap[idx_usize] == u32::MAX {
1296                    remap[idx_usize] = positions.len() as u32;
1297                    let p = self.positions[idx_usize];
1298                    positions.push(Vec3::new(p[0], p[1], p[2]));
1299                }
1300                out[slot] = remap[idx_usize];
1301            }
1302            tets.push(out);
1303        }
1304
1305        Ok((
1306            super::tetmesh::TetMesh::new(positions, tets),
1307            ConversionReport {
1308                dropped_non_tet_cells: dropped,
1309            },
1310        ))
1311    }
1312}
1313
1314/// Side data returned by [`VolumeMeshData::to_tet_mesh`].
1315#[derive(Clone, Debug, Default, PartialEq)]
1316pub struct ConversionReport {
1317    /// Non-tet cells (pyramid, wedge, hex) dropped during extraction.
1318    pub dropped_non_tet_cells: usize,
1319}
1320
1321/// Error returned by [`VolumeMeshData::to_tet_mesh`].
1322#[derive(Debug)]
1323pub enum ToTetMeshError {
1324    /// The mesh contained no tetrahedral cells.
1325    NoTetCells,
1326    /// A cell referenced a vertex index beyond the position array.
1327    OutOfRangeIndex(u32),
1328}
1329
1330// ---------------------------------------------------------------------------
1331// Tet decomposition for transparent volume rendering
1332// ---------------------------------------------------------------------------
1333
1334/// Hex-to-tet decomposition using the Freudenthal 6-tet split.
1335///
1336/// All 6 tets share the main diagonal (vertex 0 <-> vertex 6 in VTK hex ordering).
1337const HEX_TO_TETS: [[usize; 4]; 6] = [
1338    [0, 1, 5, 6],
1339    [0, 1, 2, 6],
1340    [0, 4, 5, 6],
1341    [0, 4, 7, 6],
1342    [0, 3, 2, 6],
1343    [0, 3, 7, 6],
1344];
1345
1346/// Wedge-to-tet decomposition (3 tets from a triangular prism).
1347///
1348/// Vertices: 0,1,2 = bottom triangle; 3,4,5 = top triangle (3 above 0, etc.).
1349const WEDGE_TO_TETS: [[usize; 4]; 3] = [[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]];
1350
1351/// Pyramid-to-tet decomposition (2 tets from a square pyramid).
1352///
1353/// Vertices: 0-3 = base quad; 4 = apex.
1354const PYRAMID_TO_TETS: [[usize; 4]; 2] = [[0, 1, 2, 4], [0, 2, 3, 4]];
1355
1356/// Call `f` once per output tetrahedron across all cells in `data`.
1357///
1358/// `f` receives the four world-space vertices and the scalar value for that tet.
1359/// The scalar is taken from `data.cell_scalars[attribute]` at the parent cell index,
1360/// or 0.0 when the attribute is absent or the cell index is out of range.
1361///
1362/// Cell decomposition:
1363/// - Tet -> 1 tet
1364/// - Pyramid -> 2 tets
1365/// - Wedge -> 3 tets
1366/// - Hex -> 6 tets (Freudenthal split)
1367pub(crate) fn for_each_tet<F>(data: &VolumeMeshData, attribute: &str, mut f: F)
1368where
1369    F: FnMut([[f32; 3]; 4], f32),
1370{
1371    let cell_scalars = data.cell_scalars.get(attribute);
1372    for (cell_idx, cell) in data.cells.iter().enumerate() {
1373        let scalar = cell_scalars
1374            .and_then(|v| v.get(cell_idx))
1375            .copied()
1376            .unwrap_or(0.0);
1377        let tets: &[[usize; 4]] = match cell_type(cell) {
1378            CellType::Tet => &[[0, 1, 2, 3]],
1379            CellType::Pyramid => &PYRAMID_TO_TETS,
1380            CellType::Wedge => &WEDGE_TO_TETS,
1381            CellType::Hex => &HEX_TO_TETS,
1382        };
1383        for local in tets {
1384            let verts = [
1385                data.positions[cell[local[0]] as usize],
1386                data.positions[cell[local[1]] as usize],
1387                data.positions[cell[local[2]] as usize],
1388                data.positions[cell[local[3]] as usize],
1389            ];
1390            f(verts, scalar);
1391        }
1392    }
1393}
1394
1395/// Decompose all cells in `data` into tetrahedra and collect the results.
1396///
1397/// Returns `(positions, scalars)`:
1398/// - `positions`: flat list of `[[f32; 3]; 4]`, one entry per output tet (4 world-space vertices)
1399/// - `scalars`: one `f32` per output tet, taken from `data.cell_scalars[attribute]` at the
1400///   parent cell index (0.0 when the attribute is absent or the cell index is out of range)
1401///
1402/// Used in tests. Production upload paths use `for_each_tet` directly to avoid
1403/// materializing the full decomposed data before chunking.
1404#[cfg(test)]
1405pub(crate) fn decompose_to_tetrahedra(
1406    data: &VolumeMeshData,
1407    attribute: &str,
1408) -> (Vec<[[f32; 3]; 4]>, Vec<f32>) {
1409    let mut positions: Vec<[[f32; 3]; 4]> = Vec::new();
1410    let mut scalars: Vec<f32> = Vec::new();
1411    for_each_tet(data, attribute, |verts, scalar| {
1412        positions.push(verts);
1413        scalars.push(scalar);
1414    });
1415    (positions, scalars)
1416}
1417
1418// ---------------------------------------------------------------------------
1419// Tests
1420// ---------------------------------------------------------------------------
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::*;
1425
1426    const TEST_TET_LOCAL: [[usize; 4]; 6] = [
1427        [0, 1, 5, 6],
1428        [0, 1, 2, 6],
1429        [0, 4, 5, 6],
1430        [0, 4, 7, 6],
1431        [0, 3, 2, 6],
1432        [0, 3, 7, 6],
1433    ];
1434
1435    fn single_tet() -> VolumeMeshData {
1436        VolumeMeshData {
1437            positions: vec![
1438                [0.0, 0.0, 0.0],
1439                [1.0, 0.0, 0.0],
1440                [0.5, 1.0, 0.0],
1441                [0.5, 0.5, 1.0],
1442            ],
1443            cells: vec![[
1444                0,
1445                1,
1446                2,
1447                3,
1448                CELL_SENTINEL,
1449                CELL_SENTINEL,
1450                CELL_SENTINEL,
1451                CELL_SENTINEL,
1452            ]],
1453            ..Default::default()
1454        }
1455    }
1456
1457    fn two_tets_sharing_face() -> VolumeMeshData {
1458        // Two tets glued along face [0, 1, 2].
1459        // Tet A: [0,1,2,3], Tet B: [0,2,1,4]  (reversed to share face outwardly)
1460        VolumeMeshData {
1461            positions: vec![
1462                [0.0, 0.0, 0.0],
1463                [1.0, 0.0, 0.0],
1464                [0.5, 1.0, 0.0],
1465                [0.5, 0.5, 1.0],
1466                [0.5, 0.5, -1.0],
1467            ],
1468            cells: vec![
1469                [
1470                    0,
1471                    1,
1472                    2,
1473                    3,
1474                    CELL_SENTINEL,
1475                    CELL_SENTINEL,
1476                    CELL_SENTINEL,
1477                    CELL_SENTINEL,
1478                ],
1479                [
1480                    0,
1481                    2,
1482                    1,
1483                    4,
1484                    CELL_SENTINEL,
1485                    CELL_SENTINEL,
1486                    CELL_SENTINEL,
1487                    CELL_SENTINEL,
1488                ],
1489            ],
1490            ..Default::default()
1491        }
1492    }
1493
1494    fn single_hex() -> VolumeMeshData {
1495        VolumeMeshData {
1496            positions: vec![
1497                [0.0, 0.0, 0.0], // 0
1498                [1.0, 0.0, 0.0], // 1
1499                [1.0, 0.0, 1.0], // 2
1500                [0.0, 0.0, 1.0], // 3
1501                [0.0, 1.0, 0.0], // 4
1502                [1.0, 1.0, 0.0], // 5
1503                [1.0, 1.0, 1.0], // 6
1504                [0.0, 1.0, 1.0], // 7
1505            ],
1506            cells: vec![[0, 1, 2, 3, 4, 5, 6, 7]],
1507            ..Default::default()
1508        }
1509    }
1510
1511    fn structured_tet_grid(grid_n: usize) -> VolumeMeshData {
1512        let grid_v = grid_n + 1;
1513        let vid =
1514            |ix: usize, iy: usize, iz: usize| (iz * grid_v * grid_v + iy * grid_v + ix) as u32;
1515
1516        let mut positions = Vec::with_capacity(grid_v * grid_v * grid_v);
1517        for iz in 0..grid_v {
1518            for iy in 0..grid_v {
1519                for ix in 0..grid_v {
1520                    positions.push([ix as f32, iy as f32, iz as f32]);
1521                }
1522            }
1523        }
1524
1525        let mut cells = Vec::with_capacity(grid_n * grid_n * grid_n * TEST_TET_LOCAL.len());
1526        for iz in 0..grid_n {
1527            for iy in 0..grid_n {
1528                for ix in 0..grid_n {
1529                    let cube_verts = [
1530                        vid(ix, iy, iz),
1531                        vid(ix + 1, iy, iz),
1532                        vid(ix + 1, iy, iz + 1),
1533                        vid(ix, iy, iz + 1),
1534                        vid(ix, iy + 1, iz),
1535                        vid(ix + 1, iy + 1, iz),
1536                        vid(ix + 1, iy + 1, iz + 1),
1537                        vid(ix, iy + 1, iz + 1),
1538                    ];
1539                    for tet in &TEST_TET_LOCAL {
1540                        cells.push([
1541                            cube_verts[tet[0]],
1542                            cube_verts[tet[1]],
1543                            cube_verts[tet[2]],
1544                            cube_verts[tet[3]],
1545                            CELL_SENTINEL,
1546                            CELL_SENTINEL,
1547                            CELL_SENTINEL,
1548                            CELL_SENTINEL,
1549                        ]);
1550                    }
1551                }
1552            }
1553        }
1554
1555        VolumeMeshData {
1556            positions,
1557            cells,
1558            ..Default::default()
1559        }
1560    }
1561
1562    fn projected_sphere_tet_grid(grid_n: usize, radius: f32) -> VolumeMeshData {
1563        let grid_v = grid_n + 1;
1564        let half = grid_n as f32 / 2.0;
1565        let vid =
1566            |ix: usize, iy: usize, iz: usize| (iz * grid_v * grid_v + iy * grid_v + ix) as u32;
1567
1568        let mut positions = Vec::with_capacity(grid_v * grid_v * grid_v);
1569        for iz in 0..grid_v {
1570            for iy in 0..grid_v {
1571                for ix in 0..grid_v {
1572                    let x = ix as f32 - half;
1573                    let y = iy as f32 - half;
1574                    let z = iz as f32 - half;
1575                    let len = (x * x + y * y + z * z).sqrt();
1576                    let s = radius / len;
1577                    positions.push([x * s, y * s, z * s]);
1578                }
1579            }
1580        }
1581
1582        let mut cells = Vec::with_capacity(grid_n * grid_n * grid_n * TEST_TET_LOCAL.len());
1583        for iz in 0..grid_n {
1584            for iy in 0..grid_n {
1585                for ix in 0..grid_n {
1586                    let cube_verts = [
1587                        vid(ix, iy, iz),
1588                        vid(ix + 1, iy, iz),
1589                        vid(ix + 1, iy, iz + 1),
1590                        vid(ix, iy, iz + 1),
1591                        vid(ix, iy + 1, iz),
1592                        vid(ix + 1, iy + 1, iz),
1593                        vid(ix + 1, iy + 1, iz + 1),
1594                        vid(ix, iy + 1, iz + 1),
1595                    ];
1596                    for tet in &TEST_TET_LOCAL {
1597                        cells.push([
1598                            cube_verts[tet[0]],
1599                            cube_verts[tet[1]],
1600                            cube_verts[tet[2]],
1601                            cube_verts[tet[3]],
1602                            CELL_SENTINEL,
1603                            CELL_SENTINEL,
1604                            CELL_SENTINEL,
1605                            CELL_SENTINEL,
1606                        ]);
1607                    }
1608                }
1609            }
1610        }
1611
1612        VolumeMeshData {
1613            positions,
1614            cells,
1615            ..Default::default()
1616        }
1617    }
1618
1619    fn cube_to_sphere([x, y, z]: [f32; 3]) -> [f32; 3] {
1620        let x2 = x * x;
1621        let y2 = y * y;
1622        let z2 = z * z;
1623        [
1624            x * (1.0 - 0.5 * (y2 + z2) + (y2 * z2) / 3.0).sqrt(),
1625            y * (1.0 - 0.5 * (z2 + x2) + (z2 * x2) / 3.0).sqrt(),
1626            z * (1.0 - 0.5 * (x2 + y2) + (x2 * y2) / 3.0).sqrt(),
1627        ]
1628    }
1629
1630    fn cube_sphere_hex_grid(grid_n: usize, radius: f32) -> VolumeMeshData {
1631        let grid_v = grid_n + 1;
1632        let half = grid_n as f32 / 2.0;
1633        let vid =
1634            |ix: usize, iy: usize, iz: usize| (iz * grid_v * grid_v + iy * grid_v + ix) as u32;
1635
1636        let mut positions = Vec::with_capacity(grid_v * grid_v * grid_v);
1637        for iz in 0..grid_v {
1638            for iy in 0..grid_v {
1639                for ix in 0..grid_v {
1640                    let p = [ix as f32 - half, iy as f32 - half, iz as f32 - half];
1641                    let cube = [p[0] / half, p[1] / half, p[2] / half];
1642                    let s = cube_to_sphere(cube);
1643                    positions.push([s[0] * radius, s[1] * radius, s[2] * radius]);
1644                }
1645            }
1646        }
1647
1648        let mut cells = Vec::with_capacity(grid_n * grid_n * grid_n);
1649        for iz in 0..grid_n {
1650            for iy in 0..grid_n {
1651                for ix in 0..grid_n {
1652                    cells.push([
1653                        vid(ix, iy, iz),
1654                        vid(ix + 1, iy, iz),
1655                        vid(ix + 1, iy, iz + 1),
1656                        vid(ix, iy, iz + 1),
1657                        vid(ix, iy + 1, iz),
1658                        vid(ix + 1, iy + 1, iz),
1659                        vid(ix + 1, iy + 1, iz + 1),
1660                        vid(ix, iy + 1, iz + 1),
1661                    ]);
1662                }
1663            }
1664        }
1665
1666        VolumeMeshData {
1667            positions,
1668            cells,
1669            ..Default::default()
1670        }
1671    }
1672
1673    fn structured_hex_grid(grid_n: usize) -> VolumeMeshData {
1674        let grid_v = grid_n + 1;
1675        let vid =
1676            |ix: usize, iy: usize, iz: usize| (iz * grid_v * grid_v + iy * grid_v + ix) as u32;
1677
1678        let mut positions = Vec::with_capacity(grid_v * grid_v * grid_v);
1679        for iz in 0..grid_v {
1680            for iy in 0..grid_v {
1681                for ix in 0..grid_v {
1682                    positions.push([ix as f32, iy as f32, iz as f32]);
1683                }
1684            }
1685        }
1686
1687        let mut cells = Vec::with_capacity(grid_n * grid_n * grid_n);
1688        for iz in 0..grid_n {
1689            for iy in 0..grid_n {
1690                for ix in 0..grid_n {
1691                    cells.push([
1692                        vid(ix, iy, iz),
1693                        vid(ix + 1, iy, iz),
1694                        vid(ix + 1, iy, iz + 1),
1695                        vid(ix, iy, iz + 1),
1696                        vid(ix, iy + 1, iz),
1697                        vid(ix + 1, iy + 1, iz),
1698                        vid(ix + 1, iy + 1, iz + 1),
1699                        vid(ix, iy + 1, iz + 1),
1700                    ]);
1701                }
1702            }
1703        }
1704
1705        VolumeMeshData {
1706            positions,
1707            cells,
1708            ..Default::default()
1709        }
1710    }
1711
1712    #[test]
1713    fn single_tet_has_four_boundary_faces() {
1714        let data = single_tet();
1715        let (mesh, _) = extract_boundary_faces(&data);
1716        assert_eq!(
1717            mesh.indices.len(),
1718            4 * 3,
1719            "single tet -> 4 boundary triangles"
1720        );
1721    }
1722
1723    #[test]
1724    fn two_tets_sharing_face_eliminates_shared_face() {
1725        let data = two_tets_sharing_face();
1726        let (mesh, _) = extract_boundary_faces(&data);
1727        // 4 + 4 - 2 = 6 boundary triangles (shared face contributes 2 tris
1728        // that cancel, leaving 6)
1729        assert_eq!(
1730            mesh.indices.len(),
1731            6 * 3,
1732            "two tets sharing a face -> 6 boundary triangles"
1733        );
1734    }
1735
1736    #[test]
1737    fn single_hex_has_twelve_boundary_triangles() {
1738        let data = single_hex();
1739        let (mesh, _) = extract_boundary_faces(&data);
1740        // 6 quad faces x 2 triangles each = 12
1741        assert_eq!(
1742            mesh.indices.len(),
1743            12 * 3,
1744            "single hex -> 12 boundary triangles"
1745        );
1746    }
1747
1748    #[test]
1749    fn structured_tet_grid_has_expected_boundary_triangle_count() {
1750        let grid_n = 3;
1751        let data = structured_tet_grid(grid_n);
1752        let (mesh, _) = extract_boundary_faces(&data);
1753        let expected_boundary_tris = 6 * grid_n * grid_n * 2;
1754        assert_eq!(
1755            mesh.indices.len(),
1756            expected_boundary_tris * 3,
1757            "3x3x3 tet grid should expose 108 boundary triangles"
1758        );
1759    }
1760
1761    #[test]
1762    fn structured_hex_grid_has_expected_boundary_triangle_count() {
1763        let grid_n = 3;
1764        let data = structured_hex_grid(grid_n);
1765        let (mesh, _) = extract_boundary_faces(&data);
1766        let expected_boundary_tris = 6 * grid_n * grid_n * 2;
1767        assert_eq!(
1768            mesh.indices.len(),
1769            expected_boundary_tris * 3,
1770            "3x3x3 hex grid should expose 108 boundary triangles"
1771        );
1772    }
1773
1774    #[test]
1775    fn structured_tet_grid_boundary_is_edge_manifold() {
1776        let data = structured_tet_grid(3);
1777        let (mesh, _) = extract_boundary_faces(&data);
1778
1779        let mut edge_counts: std::collections::HashMap<(u32, u32), usize> =
1780            std::collections::HashMap::new();
1781        for tri in mesh.indices.chunks_exact(3) {
1782            for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
1783                let edge = if a < b { (a, b) } else { (b, a) };
1784                *edge_counts.entry(edge).or_insert(0) += 1;
1785            }
1786        }
1787
1788        let non_manifold: Vec<((u32, u32), usize)> = edge_counts
1789            .into_iter()
1790            .filter(|(_, count)| *count != 2)
1791            .collect();
1792
1793        assert!(
1794            non_manifold.is_empty(),
1795            "boundary should be watertight; bad edges: {non_manifold:?}"
1796        );
1797    }
1798
1799    #[test]
1800    fn structured_hex_grid_boundary_is_edge_manifold() {
1801        let data = structured_hex_grid(3);
1802        let (mesh, _) = extract_boundary_faces(&data);
1803
1804        let mut edge_counts: std::collections::HashMap<(u32, u32), usize> =
1805            std::collections::HashMap::new();
1806        for tri in mesh.indices.chunks_exact(3) {
1807            for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
1808                let edge = if a < b { (a, b) } else { (b, a) };
1809                *edge_counts.entry(edge).or_insert(0) += 1;
1810            }
1811        }
1812
1813        let non_manifold: Vec<((u32, u32), usize)> = edge_counts
1814            .into_iter()
1815            .filter(|(_, count)| *count != 2)
1816            .collect();
1817
1818        assert!(
1819            non_manifold.is_empty(),
1820            "boundary should be watertight; bad edges: {non_manifold:?}"
1821        );
1822    }
1823
1824    #[test]
1825    fn projected_sphere_tet_grid_boundary_faces_point_outward() {
1826        let data = projected_sphere_tet_grid(3, 2.0);
1827        let (mesh, _) = extract_boundary_faces(&data);
1828
1829        for tri in mesh.indices.chunks_exact(3) {
1830            let pa = mesh.positions[tri[0] as usize];
1831            let pb = mesh.positions[tri[1] as usize];
1832            let pc = mesh.positions[tri[2] as usize];
1833
1834            let ab = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
1835            let ac = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
1836            let normal = [
1837                ab[1] * ac[2] - ab[2] * ac[1],
1838                ab[2] * ac[0] - ab[0] * ac[2],
1839                ab[0] * ac[1] - ab[1] * ac[0],
1840            ];
1841            let fc = [
1842                (pa[0] + pb[0] + pc[0]) / 3.0,
1843                (pa[1] + pb[1] + pc[1]) / 3.0,
1844                (pa[2] + pb[2] + pc[2]) / 3.0,
1845            ];
1846            let dot = normal[0] * fc[0] + normal[1] * fc[1] + normal[2] * fc[2];
1847            assert!(
1848                dot > 0.0,
1849                "boundary face points inward: tri={tri:?}, dot={dot}"
1850            );
1851        }
1852    }
1853
1854    #[test]
1855    fn cube_sphere_hex_grid_boundary_faces_point_outward() {
1856        let data = cube_sphere_hex_grid(3, 2.0);
1857        let (mesh, _) = extract_boundary_faces(&data);
1858
1859        for tri in mesh.indices.chunks_exact(3) {
1860            let pa = mesh.positions[tri[0] as usize];
1861            let pb = mesh.positions[tri[1] as usize];
1862            let pc = mesh.positions[tri[2] as usize];
1863
1864            let ab = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
1865            let ac = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
1866            let normal = [
1867                ab[1] * ac[2] - ab[2] * ac[1],
1868                ab[2] * ac[0] - ab[0] * ac[2],
1869                ab[0] * ac[1] - ab[1] * ac[0],
1870            ];
1871            let fc = [
1872                (pa[0] + pb[0] + pc[0]) / 3.0,
1873                (pa[1] + pb[1] + pc[1]) / 3.0,
1874                (pa[2] + pb[2] + pc[2]) / 3.0,
1875            ];
1876            let dot = normal[0] * fc[0] + normal[1] * fc[1] + normal[2] * fc[2];
1877            assert!(
1878                dot > 0.0,
1879                "boundary face points inward: tri={tri:?}, dot={dot}"
1880            );
1881        }
1882    }
1883
1884    #[test]
1885    fn normals_have_correct_length() {
1886        let data = single_tet();
1887        let (mesh, _) = extract_boundary_faces(&data);
1888        assert_eq!(mesh.normals.len(), mesh.positions.len());
1889        for n in &mesh.normals {
1890            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1891            assert!(
1892                (len - 1.0).abs() < 1e-5 || len < 1e-5,
1893                "normal not unit: {n:?}"
1894            );
1895        }
1896    }
1897
1898    #[test]
1899    fn cell_scalar_remaps_to_face_attribute() {
1900        let mut data = single_tet();
1901        data.cell_scalars.insert("pressure".to_string(), vec![42.0]);
1902        let (mesh, _) = extract_boundary_faces(&data);
1903        match mesh.attributes.get("pressure") {
1904            Some(AttributeData::Face(vals)) => {
1905                assert_eq!(vals.len(), 4, "one value per boundary triangle");
1906                for &v in vals {
1907                    assert_eq!(v, 42.0);
1908                }
1909            }
1910            other => panic!("expected Face attribute, got {other:?}"),
1911        }
1912    }
1913
1914    #[test]
1915    fn cell_colour_remaps_to_face_colour_attribute() {
1916        let mut data = two_tets_sharing_face();
1917        data.cell_colours.insert(
1918            "label".to_string(),
1919            vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
1920        );
1921        let (mesh, _) = extract_boundary_faces(&data);
1922        match mesh.attributes.get("label") {
1923            Some(AttributeData::FaceColour(colours)) => {
1924                assert_eq!(colours.len(), 6, "6 boundary faces");
1925            }
1926            other => panic!("expected FaceColour attribute, got {other:?}"),
1927        }
1928    }
1929
1930    #[test]
1931    fn positions_preserved_unchanged() {
1932        let data = single_hex();
1933        let (mesh, _) = extract_boundary_faces(&data);
1934        assert_eq!(mesh.positions, data.positions);
1935    }
1936
1937    // -----------------------------------------------------------------------
1938    // Executable specifications for extract_clipped_volume_faces.
1939    // These tests document the required invariants and are currently ignored.
1940    // Enable them by removing #[ignore].
1941    // -----------------------------------------------------------------------
1942
1943    /// Empty clip-plane slice must produce the same triangles as the boundary
1944    /// extractor (the clipped path degenerates to an unclipped boundary extraction
1945    /// when no planes are active).
1946    #[test]
1947
1948    fn empty_planes_matches_boundary_extractor_tet() {
1949        let data = structured_tet_grid(3);
1950        let (boundary, _) = extract_boundary_faces(&data);
1951        let (clipped, _) = extract_clipped_volume_faces(&data, &[]);
1952        assert_eq!(
1953            boundary.indices.len(),
1954            clipped.indices.len(),
1955            "empty clip_planes -> same triangle count as extract_boundary_faces"
1956        );
1957    }
1958
1959    /// Empty clip-plane slice must produce the same triangles as the boundary
1960    /// extractor for hex meshes.
1961    #[test]
1962
1963    fn empty_planes_matches_boundary_extractor_hex() {
1964        let data = structured_hex_grid(3);
1965        let (boundary, _) = extract_boundary_faces(&data);
1966        let (clipped, _) = extract_clipped_volume_faces(&data, &[]);
1967        assert_eq!(
1968            boundary.indices.len(),
1969            clipped.indices.len(),
1970            "empty clip_planes -> same triangle count as extract_boundary_faces"
1971        );
1972    }
1973
1974    /// Clipping a tet grid through its centre must produce non-empty section
1975    /// faces (i.e. the cut face count is greater than zero).
1976    #[test]
1977
1978    fn clipped_tet_grid_has_nonempty_section_faces() {
1979        let grid_n = 3;
1980        let data = structured_tet_grid(grid_n);
1981        // Y = 1.5 cuts through the middle of a 3-unit-tall grid.
1982        // Plane: ny=1, d=-1.5  ->  dot(p,[0,1,0]) - 1.5 >= 0  ->  keep y >= 1.5.
1983        let plane = [0.0_f32, 1.0, 0.0, -1.5];
1984        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
1985        // Some triangles must come from section faces.
1986        assert!(
1987            !mesh.indices.is_empty(),
1988            "clipped tet grid must produce at least one triangle"
1989        );
1990    }
1991
1992    /// Clipping a hex grid through its centre must produce non-empty section faces.
1993    #[test]
1994
1995    fn clipped_hex_grid_has_nonempty_section_faces() {
1996        let grid_n = 3;
1997        let data = structured_hex_grid(grid_n);
1998        let plane = [0.0_f32, 1.0, 0.0, -1.5];
1999        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2000        assert!(
2001            !mesh.indices.is_empty(),
2002            "clipped hex grid must produce at least one triangle"
2003        );
2004    }
2005
2006    /// Section face normals must point toward the kept side of the cutting
2007    /// plane (dot of the section face normal with the plane normal > 0).
2008    #[test]
2009
2010    fn section_face_normals_point_toward_kept_side_tet() {
2011        let data = structured_tet_grid(3);
2012        let plane_normal = [0.0_f32, 1.0, 0.0];
2013        let plane = [plane_normal[0], plane_normal[1], plane_normal[2], -1.5];
2014        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2015
2016        for n in &mesh.normals {
2017            let dot = n[0] * plane_normal[0] + n[1] * plane_normal[1] + n[2] * plane_normal[2];
2018            // Only section faces are required to satisfy this; boundary normals
2019            // may point in any outward direction.  The test checks that no
2020            // normal is strongly anti-parallel to the plane normal.
2021            // (A full test would distinguish section faces from boundary faces.)
2022            let _ = dot; // placeholder until section faces can be identified
2023        }
2024    }
2025
2026    /// A cell fully on the discarded side of a clip plane contributes no triangles.
2027    #[test]
2028
2029    fn fully_discarded_cells_contribute_nothing() {
2030        // Single tet at y=0..1 ; plane keeps y >= 2.0 -> tet is fully discarded.
2031        let data = single_tet();
2032        let plane = [0.0_f32, 1.0, 0.0, -2.0]; // keep y >= 2.0
2033        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2034        assert!(
2035            mesh.indices.is_empty(),
2036            "tet fully below clip plane must produce no triangles"
2037        );
2038    }
2039
2040    /// A cell fully on the kept side of a clip plane contributes the same
2041    /// boundary triangles as the unclipped extractor.
2042    #[test]
2043
2044    fn fully_kept_cell_matches_boundary_extractor() {
2045        // Single tet at y=0..1 ; plane keeps y >= -1.0 -> tet is fully kept.
2046        let data = single_tet();
2047        let plane = [0.0_f32, 1.0, 0.0, 1.0]; // keep y >= -1.0
2048        let (clipped, _) = extract_clipped_volume_faces(&data, &[plane]);
2049        let (boundary, _) = extract_boundary_faces(&data);
2050        assert_eq!(
2051            clipped.indices.len(),
2052            boundary.indices.len(),
2053            "fully kept cell must produce the same triangles as boundary extractor"
2054        );
2055    }
2056
2057    /// Cell scalar attributes must be remapped onto section triangles in the
2058    /// same way they are remapped onto boundary triangles.
2059    #[test]
2060    fn cell_scalar_propagates_to_section_faces() {
2061        let mut data = structured_tet_grid(3);
2062        let n_cells = data.cells.len();
2063        data.cell_scalars
2064            .insert("pressure".to_string(), vec![1.0; n_cells]);
2065        let plane = [0.0_f32, 1.0, 0.0, -1.5];
2066        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2067        match mesh.attributes.get("pressure") {
2068            Some(AttributeData::Face(vals)) => {
2069                let n_tris = mesh.indices.len() / 3;
2070                assert_eq!(vals.len(), n_tris, "one scalar value per output triangle");
2071                for &v in vals {
2072                    assert_eq!(v, 1.0, "scalar must equal the owning cell's value");
2073                }
2074            }
2075            other => panic!("expected Face attribute on clipped mesh, got {other:?}"),
2076        }
2077    }
2078
2079    /// Cell colour attributes must be remapped onto section triangles as
2080    /// `AttributeKind::FaceColour`, with one entry per output triangle.
2081    #[test]
2082    fn cell_colour_propagates_to_section_faces() {
2083        let mut data = structured_tet_grid(3);
2084        let n_cells = data.cells.len();
2085        let colour = [1.0_f32, 0.0, 0.5, 1.0];
2086        data.cell_colours
2087            .insert("label".to_string(), vec![colour; n_cells]);
2088        let plane = [0.0_f32, 1.0, 0.0, -1.5];
2089        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2090        match mesh.attributes.get("label") {
2091            Some(AttributeData::FaceColour(colours)) => {
2092                let n_tris = mesh.indices.len() / 3;
2093                assert_eq!(colours.len(), n_tris, "one colour per output triangle");
2094                for &c in colours {
2095                    assert_eq!(c, colour, "colour must equal the owning cell's value");
2096                }
2097            }
2098            other => panic!("expected FaceColour attribute on clipped mesh, got {other:?}"),
2099        }
2100    }
2101
2102    /// Section faces for hex cells must also carry per-cell scalar attributes.
2103    #[test]
2104    fn hex_cell_scalar_propagates_to_section_faces() {
2105        let mut data = structured_hex_grid(3);
2106        let n_cells = data.cells.len();
2107        data.cell_scalars
2108            .insert("temp".to_string(), vec![7.0; n_cells]);
2109        let plane = [0.0_f32, 1.0, 0.0, -1.5];
2110        let (mesh, _) = extract_clipped_volume_faces(&data, &[plane]);
2111        match mesh.attributes.get("temp") {
2112            Some(AttributeData::Face(vals)) => {
2113                let n_tris = mesh.indices.len() / 3;
2114                assert_eq!(vals.len(), n_tris, "one scalar per output triangle");
2115                for &v in vals {
2116                    assert_eq!(v, 7.0, "scalar must equal the owning cell's value");
2117                }
2118            }
2119            other => panic!("expected Face attribute on clipped hex mesh, got {other:?}"),
2120        }
2121    }
2122
2123    // -----------------------------------------------------------------------
2124    // decompose_to_tetrahedra
2125    // -----------------------------------------------------------------------
2126
2127    fn single_pyramid() -> VolumeMeshData {
2128        // Square base at y=0, apex at y=1.
2129        let mut data = VolumeMeshData {
2130            positions: vec![
2131                [0.0, 0.0, 0.0], // 0
2132                [1.0, 0.0, 0.0], // 1
2133                [1.0, 0.0, 1.0], // 2
2134                [0.0, 0.0, 1.0], // 3
2135                [0.5, 1.0, 0.5], // 4 apex
2136            ],
2137            ..Default::default()
2138        };
2139        data.push_pyramid([0, 1, 2, 3], 4);
2140        data
2141    }
2142
2143    fn single_wedge() -> VolumeMeshData {
2144        // Two triangular faces: tri0 at y=0, tri1 at y=1.
2145        let mut data = VolumeMeshData {
2146            positions: vec![
2147                [0.0, 0.0, 0.0], // 0
2148                [1.0, 0.0, 0.0], // 1
2149                [0.5, 0.0, 1.0], // 2
2150                [0.0, 1.0, 0.0], // 3
2151                [1.0, 1.0, 0.0], // 4
2152                [0.5, 1.0, 1.0], // 5
2153            ],
2154            ..Default::default()
2155        };
2156        data.push_wedge([0, 1, 2], [3, 4, 5]);
2157        data
2158    }
2159
2160    fn tet_volume(p: [[f32; 3]; 4]) -> f32 {
2161        // Signed volume = dot(v1, cross(v2, v3)) / 6 where vi = pi - p0.
2162        let v =
2163            |i: usize| -> [f32; 3] { [p[i][0] - p[0][0], p[i][1] - p[0][1], p[i][2] - p[0][2]] };
2164        let (a, b, c) = (v(1), v(2), v(3));
2165        let cross = [
2166            b[1] * c[2] - b[2] * c[1],
2167            b[2] * c[0] - b[0] * c[2],
2168            b[0] * c[1] - b[1] * c[0],
2169        ];
2170        (a[0] * cross[0] + a[1] * cross[1] + a[2] * cross[2]) / 6.0
2171    }
2172
2173    #[test]
2174    fn decompose_tet_yields_one_tet() {
2175        let data = single_tet();
2176        let (tets, scalars) = decompose_to_tetrahedra(&data, "");
2177        assert_eq!(tets.len(), 1);
2178        assert_eq!(scalars.len(), 1);
2179    }
2180
2181    #[test]
2182    fn decompose_hex_yields_six_tets() {
2183        let data = single_hex();
2184        let (tets, scalars) = decompose_to_tetrahedra(&data, "");
2185        assert_eq!(tets.len(), 6);
2186        assert_eq!(scalars.len(), 6);
2187    }
2188
2189    #[test]
2190    fn decompose_pyramid_yields_two_tets() {
2191        let data = single_pyramid();
2192        let (tets, scalars) = decompose_to_tetrahedra(&data, "");
2193        assert_eq!(tets.len(), 2);
2194        assert_eq!(scalars.len(), 2);
2195    }
2196
2197    #[test]
2198    fn decompose_wedge_yields_three_tets() {
2199        let data = single_wedge();
2200        let (tets, scalars) = decompose_to_tetrahedra(&data, "");
2201        assert_eq!(tets.len(), 3);
2202        assert_eq!(scalars.len(), 3);
2203    }
2204
2205    #[test]
2206    fn decompose_output_tets_have_nonzero_volume() {
2207        for data in [single_tet(), single_hex(), single_pyramid(), single_wedge()] {
2208            let (tets, _) = decompose_to_tetrahedra(&data, "");
2209            for (i, t) in tets.iter().enumerate() {
2210                let vol = tet_volume(*t).abs();
2211                assert!(vol > 1e-6, "tet {i} has near-zero volume {vol}: {t:?}");
2212            }
2213        }
2214    }
2215
2216    #[test]
2217    fn decompose_hex_volume_equals_cell_volume() {
2218        // The 6-tet decomposition of a unit cube must sum to 1.0.
2219        let data = single_hex();
2220        let (tets, _) = decompose_to_tetrahedra(&data, "");
2221        let total: f32 = tets.iter().map(|t| tet_volume(*t).abs()).sum();
2222        assert!(
2223            (total - 1.0).abs() < 1e-5,
2224            "unit hex volume should be 1.0, got {total}"
2225        );
2226    }
2227
2228    #[test]
2229    fn decompose_scalar_propagates_to_child_tets() {
2230        let mut data = single_hex();
2231        data.cell_scalars.insert("temp".to_string(), vec![42.0]);
2232        let (_, scalars) = decompose_to_tetrahedra(&data, "temp");
2233        assert_eq!(scalars.len(), 6);
2234        for &s in &scalars {
2235            assert_eq!(s, 42.0, "all child tets must inherit the cell scalar");
2236        }
2237    }
2238
2239    #[test]
2240    fn decompose_missing_attribute_falls_back_to_zero() {
2241        let data = single_hex();
2242        let (_, scalars) = decompose_to_tetrahedra(&data, "nonexistent");
2243        for &s in &scalars {
2244            assert_eq!(s, 0.0, "missing attribute must produce 0.0 per tet");
2245        }
2246    }
2247
2248    #[test]
2249    fn decompose_mixed_mesh_tet_counts_sum_correctly() {
2250        // One tet + one hex + one pyramid + one wedge = 1+6+2+3 = 12 tets.
2251        let mut data = VolumeMeshData {
2252            positions: vec![
2253                // tet verts (0..3)
2254                [0.0, 0.0, 0.0],
2255                [1.0, 0.0, 0.0],
2256                [0.5, 1.0, 0.0],
2257                [0.5, 0.5, 1.0],
2258                // hex verts (4..11): unit cube offset at x=2
2259                [2.0, 0.0, 0.0],
2260                [3.0, 0.0, 0.0],
2261                [3.0, 0.0, 1.0],
2262                [2.0, 0.0, 1.0],
2263                [2.0, 1.0, 0.0],
2264                [3.0, 1.0, 0.0],
2265                [3.0, 1.0, 1.0],
2266                [2.0, 1.0, 1.0],
2267                // pyramid verts (12..16): square base + apex, offset at x=4
2268                [4.0, 0.0, 0.0],
2269                [5.0, 0.0, 0.0],
2270                [5.0, 0.0, 1.0],
2271                [4.0, 0.0, 1.0],
2272                [4.5, 1.0, 0.5],
2273                // wedge verts (17..22): offset at x=6
2274                [6.0, 0.0, 0.0],
2275                [7.0, 0.0, 0.0],
2276                [6.5, 0.0, 1.0],
2277                [6.0, 1.0, 0.0],
2278                [7.0, 1.0, 0.0],
2279                [6.5, 1.0, 1.0],
2280            ],
2281            ..Default::default()
2282        };
2283        data.push_tet(0, 1, 2, 3);
2284        data.push_hex([4, 5, 6, 7, 8, 9, 10, 11]);
2285        data.push_pyramid([12, 13, 14, 15], 16);
2286        data.push_wedge([17, 18, 19], [20, 21, 22]);
2287
2288        let (tets, scalars) = decompose_to_tetrahedra(&data, "");
2289        assert_eq!(tets.len(), 12, "1+6+2+3 = 12 tets");
2290        assert_eq!(scalars.len(), 12);
2291    }
2292}