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