Skip to main content

plot3d/
flat_data.rs

1//! Flat SoA (Structure of Arrays) mesh representation for GPU finite-volume solvers.
2//!
3//! Multi-block structured grids store data in per-block (i,j,k) arrays with
4//! pointer-based block lookups. This is efficient for CPU codes but hostile to
5//! GPU architectures that need coalesced memory access across thousands of
6//! threads.
7//!
8//! This module converts a multi-block Plot3D grid into a flat, unstructured-like
9//! representation where:
10//!
11//! - Every cell has a single global integer ID (no block/i/j/k tuple).
12//! - Every face has an owner cell and a neighbor cell (or -1 for boundaries).
13//! - All geometric data (volumes, area vectors, centers) are contiguous arrays.
14//! - No pointer dereferences are needed -- every access is `array[id]`.
15//!
16//! # Face Convention
17//!
18//! Faces are categorized into three types:
19//!
20//! 1. **Interior faces** (within a block): between adjacent cells along the
21//!    i, j, or k axis. These have both `owner` and `neighbor` as valid cell IDs.
22//!
23//! 2. **Cross-block faces**: between cells in different blocks that share an
24//!    interface (from [`FaceMatch`] data). Both `owner` and `neighbor` are valid.
25//!
26//! 3. **Boundary (outer) faces**: block boundary faces that are NOT matched to
27//!    another block. These have `neighbor = -1` and carry a `surface_id` for
28//!    boundary condition assignment.
29//!
30//! The face area vector points from the owner cell toward the neighbor cell
31//! (or outward for boundary faces), consistent with finite-volume flux
32//! conventions.
33
34use crate::block::Block;
35use crate::dual_graph::{build_cell_graph, cell_index, CellGraph};
36use crate::face_record::{FaceMatch, FaceRecord};
37use crate::metrics::{compute_cell_centers, compute_cell_volumes, compute_face_metrics};
38use crate::Float;
39
40/// Flat mesh representation for finite-volume GPU solvers.
41///
42/// All arrays are contiguous and indexed by a single integer ID.
43/// Cell arrays have length `n_cells`; face arrays have length `n_faces`.
44///
45/// This layout eliminates all structured (i,j,k) indexing and multi-block
46/// pointer chains. The data is designed for coalesced GPU memory access:
47/// a warp of threads processing consecutive face IDs will read consecutive
48/// memory locations.
49#[derive(Clone, Debug)]
50pub struct FlatMesh {
51    // -- Cell data (length = n_cells) --
52
53    /// Total number of cells across all blocks.
54    pub n_cells: usize,
55    /// Cell volume computed via the divergence-theorem method.
56    /// Units: length^3. Must be positive for valid meshes.
57    pub cell_volume: Vec<Float>,
58    /// X-coordinate of cell centroid (arithmetic mean of 8 corner nodes).
59    pub cell_center_x: Vec<Float>,
60    /// Y-coordinate of cell centroid.
61    pub cell_center_y: Vec<Float>,
62    /// Z-coordinate of cell centroid.
63    pub cell_center_z: Vec<Float>,
64
65    // -- Face data (length = n_faces) --
66
67    /// Total number of faces (interior + cross-block + boundary).
68    pub n_faces: usize,
69    /// Owner cell for each face. The face area vector points away from
70    /// the owner toward the neighbor. Always a valid cell index in `[0, n_cells)`.
71    pub face_owner: Vec<u32>,
72    /// Neighbor cell for each face. For interior and cross-block faces this is
73    /// a valid cell index. For boundary faces this is `-1`.
74    pub face_neighbor: Vec<i32>,
75    /// X-component of face area vector (outward normal * area magnitude).
76    pub face_area_x: Vec<Float>,
77    /// Y-component of face area vector.
78    pub face_area_y: Vec<Float>,
79    /// Z-component of face area vector.
80    pub face_area_z: Vec<Float>,
81    /// X-coordinate of face centroid (arithmetic mean of the 4 corner nodes).
82    /// Matches Fortran's ccCoord face-centroid formula
83    /// (M_ccMBMesh.F:2528-2530), used by the solver to place ghost cell
84    /// centers via exact plane reflection rather than a cuboid V/|A|
85    /// approximation.
86    pub face_centroid_x: Vec<Float>,
87    /// Y-coordinate of face centroid.
88    pub face_centroid_y: Vec<Float>,
89    /// Z-coordinate of face centroid.
90    pub face_centroid_z: Vec<Float>,
91
92    // -- Boundary face metadata --
93
94    /// Surface ID for boundary faces (used for BC assignment).
95    /// `-1` for interior and cross-block faces.
96    pub face_surface_id: Vec<i32>,
97
98    // -- Reverse mapping (for post-processing / writing results back to Plot3D) --
99
100    /// Which original block each cell came from. Length = `n_cells`.
101    pub cell_block_id: Vec<u32>,
102    /// Local cell index within the original block. Length = `n_cells`.
103    /// To recover (i,j,k): use the block's cell dimensions from the CellGraph.
104    pub cell_local_id: Vec<u32>,
105}
106
107impl FlatMesh {
108    /// Produce a human-readable summary of the mesh statistics.
109    ///
110    /// Reports cell count, face count, boundary face count, and volume extremes.
111    /// Useful for quick sanity checks after mesh conversion.
112    pub fn stats(&self) -> String {
113        let n_boundary = self.face_neighbor.iter().filter(|&&n| n < 0).count();
114        let n_interior = self.n_faces - n_boundary;
115
116        let (min_vol, max_vol) = if self.cell_volume.is_empty() {
117            (0.0 as Float, 0.0 as Float)
118        } else {
119            let min_v = self.cell_volume.iter().cloned().fold(Float::INFINITY, Float::min);
120            let max_v = self.cell_volume.iter().cloned().fold(Float::NEG_INFINITY, Float::max);
121            (min_v, max_v)
122        };
123
124        let total_vol: Float = self.cell_volume.iter().sum();
125
126        format!(
127            "FlatMesh statistics:\n\
128             \x20 Cells:          {}\n\
129             \x20 Faces (total):  {}\n\
130             \x20   Interior:     {}\n\
131             \x20   Boundary:     {}\n\
132             \x20 Volume (total): {:.6e}\n\
133             \x20 Volume (min):   {:.6e}\n\
134             \x20 Volume (max):   {:.6e}",
135            self.n_cells, self.n_faces, n_interior, n_boundary,
136            total_vol, min_vol, max_vol,
137        )
138    }
139}
140
141/// Build a flat mesh from multi-block Plot3D data.
142///
143/// This is the central conversion function: structured blocks + connectivity
144/// data are transformed into flat, GPU-friendly arrays.
145///
146/// # Pipeline
147///
148/// 1. **Build dual graph**: compute global cell numbering via [`build_cell_graph`].
149/// 2. **Compute cell metrics**: volumes and centroids for each block using the
150///    `metrics` module, then scatter into global arrays.
151/// 3. **Build interior faces**: within each block, iterate over the three face
152///    families (I-faces, J-faces, K-faces). Each interior face connects two
153///    adjacent cells.
154/// 4. **Build cross-block faces**: from the `face_matches`, each interface cell
155///    pair becomes a face. Area vectors are taken from the metrics of whichever
156///    block "owns" the face (block1 side).
157/// 5. **Build boundary faces**: the `outer_faces` parameter lists all unmatched
158///    boundary surfaces. Each boundary face has `neighbor = -1` and carries the
159///    surface's ID for boundary condition dispatch.
160///
161/// # Arguments
162///
163/// * `blocks` - All blocks in the multi-block grid.
164/// * `face_matches` - Block-block interface connectivity.
165/// * `outer_faces` - Unmatched boundary faces with surface IDs. Each `FaceRecord`
166///   should have its `id` field set to the desired surface ID.
167///
168/// # Returns
169///
170/// A [`FlatMesh`] ready for GPU upload.
171pub fn build_flat_mesh(
172    blocks: &[Block],
173    face_matches: &[FaceMatch],
174    outer_faces: &[FaceRecord],
175) -> FlatMesh {
176    // --- Step 1: build the dual graph for global cell numbering ---
177    let graph = build_cell_graph(blocks, face_matches);
178    let n_cells = graph.n_cells;
179
180    // --- Step 2: compute and flatten cell metrics ---
181    let mut cell_volume = vec![0.0 as Float; n_cells];
182    let mut cell_center_x = vec![0.0 as Float; n_cells];
183    let mut cell_center_y = vec![0.0 as Float; n_cells];
184    let mut cell_center_z = vec![0.0 as Float; n_cells];
185    let mut cell_block_id = vec![0u32; n_cells];
186    let mut cell_local_id = vec![0u32; n_cells];
187
188    for (b, blk) in blocks.iter().enumerate() {
189        let vols = compute_cell_volumes(blk);
190        let (xc, yc, zc) = compute_cell_centers(blk);
191        let offset = graph.block_offset[b];
192        let n_local = vols.len();
193
194        for local_id in 0..n_local {
195            let gid = offset + local_id;
196            cell_volume[gid] = vols[local_id];
197            cell_center_x[gid] = xc[local_id];
198            cell_center_y[gid] = yc[local_id];
199            cell_center_z[gid] = zc[local_id];
200            cell_block_id[gid] = b as u32;
201            cell_local_id[gid] = local_id as u32;
202        }
203    }
204
205    // --- Step 3: build face lists ---
206    // Pre-compute face metrics for all blocks (we need area vectors).
207    let all_face_metrics: Vec<_> = blocks.iter().map(|blk| compute_face_metrics(blk)).collect();
208
209    // We will accumulate face data into these vectors.
210    let mut face_owner: Vec<u32> = Vec::new();
211    let mut face_neighbor: Vec<i32> = Vec::new();
212    let mut face_area_x: Vec<Float> = Vec::new();
213    let mut face_area_y: Vec<Float> = Vec::new();
214    let mut face_area_z: Vec<Float> = Vec::new();
215    let mut face_centroid_x: Vec<Float> = Vec::new();
216    let mut face_centroid_y: Vec<Float> = Vec::new();
217    let mut face_centroid_z: Vec<Float> = Vec::new();
218    let mut face_surface_id: Vec<i32> = Vec::new();
219
220    // --- 3a: Interior faces within each block ---
221    //
222    // For a block with node dimensions (ni, nj, nk) and cell dims (nci, ncj, nck):
223    //
224    // I-faces (between cells differing in i):
225    //   For i in 1..nci, j in 0..ncj, k in 0..nck:
226    //     owner  = cell(i-1, j, k)
227    //     neighbor = cell(i, j, k)
228    //     area vector from I-face metrics at (i, j, k)
229    //
230    // Similarly for J-faces and K-faces.
231
232    for (b, blk) in blocks.iter().enumerate() {
233        let ni = blk.imax;
234        let nj = blk.jmax;
235        let nk = blk.kmax;
236        let nci = ni - 1;
237        let ncj = nj - 1;
238        let nck = nk - 1;
239        let offset = graph.block_offset[b];
240        let fm = &all_face_metrics[b];
241
242        // --- I-faces (interior only: i = 1..nci-1 as node index, which is
243        //     between cell i-1 and cell i) ---
244        // I-face at node-i has face metric index: i + ni * j + ni * (nj-1) * k
245        // Interior I-faces go from node-i = 1 to node-i = nci - 1
246        // (node-i = 0 and node-i = nci = ni-1 are block boundary faces)
247        for k in 0..nck {
248            for j in 0..ncj {
249                for i in 1..nci {
250                    // This I-face separates cell (i-1,j,k) from cell (i,j,k).
251                    let owner_local = cell_index(i - 1, j, k, nci, ncj);
252                    let neighbor_local = cell_index(i, j, k, nci, ncj);
253
254                    // I-face metric index: i + ni * j + ni * (nj-1) * k
255                    let fid = i + ni * j + ni * (nj - 1) * k;
256
257                    face_owner.push((offset + owner_local) as u32);
258                    face_neighbor.push((offset + neighbor_local) as i32);
259                    face_area_x.push(fm.si_x[fid]);
260                    face_area_y.push(fm.si_y[fid]);
261                    face_area_z.push(fm.si_z[fid]);
262                    face_centroid_x.push(fm.ci_x[fid]);
263                    face_centroid_y.push(fm.ci_y[fid]);
264                    face_centroid_z.push(fm.ci_z[fid]);
265                    face_surface_id.push(-1);
266                }
267            }
268        }
269
270        // --- J-faces (interior: node-j = 1..ncj-1) ---
271        // J-face metric index: i + (ni-1) * j + (ni-1) * nj * k
272        for k in 0..nck {
273            for j in 1..ncj {
274                for i in 0..nci {
275                    let owner_local = cell_index(i, j - 1, k, nci, ncj);
276                    let neighbor_local = cell_index(i, j, k, nci, ncj);
277
278                    let fid = i + (ni - 1) * j + (ni - 1) * nj * k;
279
280                    face_owner.push((offset + owner_local) as u32);
281                    face_neighbor.push((offset + neighbor_local) as i32);
282                    face_area_x.push(fm.sj_x[fid]);
283                    face_area_y.push(fm.sj_y[fid]);
284                    face_area_z.push(fm.sj_z[fid]);
285                    face_centroid_x.push(fm.cj_x[fid]);
286                    face_centroid_y.push(fm.cj_y[fid]);
287                    face_centroid_z.push(fm.cj_z[fid]);
288                    face_surface_id.push(-1);
289                }
290            }
291        }
292
293        // --- K-faces (interior: node-k = 1..nck-1) ---
294        // K-face metric index: i + (ni-1) * j + (ni-1) * (nj-1) * k
295        for k in 1..nck {
296            for j in 0..ncj {
297                for i in 0..nci {
298                    let owner_local = cell_index(i, j, k - 1, nci, ncj);
299                    let neighbor_local = cell_index(i, j, k, nci, ncj);
300
301                    let fid = i + (ni - 1) * j + (ni - 1) * (nj - 1) * k;
302
303                    face_owner.push((offset + owner_local) as u32);
304                    face_neighbor.push((offset + neighbor_local) as i32);
305                    face_area_x.push(fm.sk_x[fid]);
306                    face_area_y.push(fm.sk_y[fid]);
307                    face_area_z.push(fm.sk_z[fid]);
308                    face_centroid_x.push(fm.ck_x[fid]);
309                    face_centroid_y.push(fm.ck_y[fid]);
310                    face_centroid_z.push(fm.ck_z[fid]);
311                    face_surface_id.push(-1);
312                }
313            }
314        }
315    }
316
317    // --- 3b: Cross-block faces from face_matches ---
318    //
319    // For each FaceMatch, the boundary cells on block1's face connect to
320    // corresponding cells on block2's face. We use block1's face metric
321    // as the area vector (it points from block1 toward block2).
322
323    for fm_match in face_matches {
324        let b1 = fm_match.block1.block_index;
325        let b2 = fm_match.block2.block_index;
326
327        let edges = cross_block_face_data(
328            b1,
329            &fm_match.block1,
330            &blocks[b1],
331            b2,
332            &fm_match.block2,
333            &blocks[b2],
334            &graph,
335            &all_face_metrics[b1],
336            fm_match.orientation.as_ref(),
337        );
338
339        for (owner, neighbor, ax, ay, az, cx, cy, cz) in edges {
340            face_owner.push(owner);
341            face_neighbor.push(neighbor as i32);
342            face_area_x.push(ax);
343            face_area_y.push(ay);
344            face_area_z.push(az);
345            face_centroid_x.push(cx);
346            face_centroid_y.push(cy);
347            face_centroid_z.push(cz);
348            face_surface_id.push(-1);
349        }
350    }
351
352    // --- 3c: Boundary (outer) faces ---
353    //
354    // Each outer face is a block boundary face that is NOT matched to another
355    // block. The neighbor is -1, and the surface_id comes from the FaceRecord.
356
357    for oface in outer_faces {
358        let b = oface.block_index;
359        let blk = &blocks[b];
360        let ni = blk.imax;
361        let nj = blk.jmax;
362        let nk = blk.kmax;
363        let nci = ni - 1;
364        let ncj = nj - 1;
365        let _nck = nk - 1;
366        let offset = graph.block_offset[b];
367        let fm = &all_face_metrics[b];
368
369        let surface_id = oface.id.map(|id| id as i32).unwrap_or(0);
370
371        // Determine which axis is constant and whether it is at the low or high end
372        let const_axis = oface.constant_axis();
373        if const_axis.is_none() {
374            continue; // Skip degenerate faces
375        }
376        let axis = const_axis.unwrap();
377        let const_vals = [oface.i_lo(), oface.j_lo(), oface.k_lo()];
378        let const_v = const_vals[axis];
379
380        let n_nodes = [ni, nj, nk];
381        let is_high = const_v == n_nodes[axis] - 1;
382
383        // Iterate over the 2D cell grid on this boundary face
384        let var_axes: Vec<usize> = (0..3).filter(|&a| a != axis).collect();
385
386        let lo = [oface.i_lo(), oface.j_lo(), oface.k_lo()];
387        let hi = [oface.i_hi(), oface.j_hi(), oface.k_hi()];
388
389        let n_u = hi[var_axes[0]] - lo[var_axes[0]];
390        let n_v = hi[var_axes[1]] - lo[var_axes[1]];
391
392        if n_u == 0 || n_v == 0 {
393            continue; // Edge or point, not a face
394        }
395
396        // The cell adjacent to this boundary face:
397        // If the face is at the low end (const_v == 0), the cell is at cell index 0 along that axis.
398        // If at the high end, the cell is at cell index n_cells_along_axis - 1.
399        let cell_const = if is_high {
400            n_nodes[axis] - 2
401        } else {
402            0
403        };
404
405        for v in 0..n_v {
406            for u in 0..n_u {
407                let mut ijk = [0usize; 3];
408                ijk[axis] = cell_const;
409                ijk[var_axes[0]] = lo[var_axes[0]] + u;
410                ijk[var_axes[1]] = lo[var_axes[1]] + v;
411
412                let gid = offset + cell_index(ijk[0], ijk[1], ijk[2], nci, ncj);
413
414                // Retrieve the face area vector from the appropriate face metric.
415                // The face metric index depends on which face family this is.
416                let (ax, ay, az) = boundary_face_area(
417                    axis, const_v, ijk, blk, fm,
418                );
419                // Face centroid (a position — unaffected by the low-end
420                // sign flip applied to the area vector below).
421                let (cx, cy, cz) = boundary_face_centroid(
422                    axis, const_v, ijk, blk, fm,
423                );
424
425                face_owner.push(gid as u32);
426                face_neighbor.push(-1);
427                // For boundary faces at the low end, the outward normal points
428                // in the -axis direction, so we negate the area vector (which
429                // by convention points in the +axis direction).
430                if !is_high {
431                    face_area_x.push(-ax);
432                    face_area_y.push(-ay);
433                    face_area_z.push(-az);
434                } else {
435                    face_area_x.push(ax);
436                    face_area_y.push(ay);
437                    face_area_z.push(az);
438                }
439                face_centroid_x.push(cx);
440                face_centroid_y.push(cy);
441                face_centroid_z.push(cz);
442                face_surface_id.push(surface_id);
443            }
444        }
445    }
446
447    let n_faces = face_owner.len();
448
449    FlatMesh {
450        n_cells,
451        cell_volume,
452        cell_center_x,
453        cell_center_y,
454        cell_center_z,
455        n_faces,
456        face_owner,
457        face_neighbor,
458        face_area_x,
459        face_area_y,
460        face_area_z,
461        face_centroid_x,
462        face_centroid_y,
463        face_centroid_z,
464        face_surface_id,
465        cell_block_id,
466        cell_local_id,
467    }
468}
469
470/// Retrieve the face area vector for a boundary face from the pre-computed
471/// face metrics.
472///
473/// `axis`: 0 = I-face, 1 = J-face, 2 = K-face.
474/// `const_v`: the node index value on the constant axis.
475/// `ijk`: the cell indices (not node indices).
476fn boundary_face_area(
477    axis: usize,
478    const_v: usize,
479    ijk: [usize; 3],
480    blk: &Block,
481    fm: &crate::metrics::FaceMetrics,
482) -> (Float, Float, Float) {
483    let ni = blk.imax;
484    let nj = blk.jmax;
485
486    match axis {
487        0 => {
488            // I-face at node-i = const_v.
489            // I-face metric index: i + ni * j + ni * (nj-1) * k
490            // Here i = const_v (the node index), j = cell-j, k = cell-k.
491            let fid = const_v + ni * ijk[1] + ni * (nj - 1) * ijk[2];
492            (fm.si_x[fid], fm.si_y[fid], fm.si_z[fid])
493        }
494        1 => {
495            // J-face at node-j = const_v.
496            // J-face metric index: i + (ni-1) * j + (ni-1) * nj * k
497            // Here i = cell-i, j = const_v, k = cell-k.
498            let fid = ijk[0] + (ni - 1) * const_v + (ni - 1) * nj * ijk[2];
499            (fm.sj_x[fid], fm.sj_y[fid], fm.sj_z[fid])
500        }
501        2 => {
502            // K-face at node-k = const_v.
503            // K-face metric index: i + (ni-1) * j + (ni-1) * (nj-1) * k
504            // Here i = cell-i, j = cell-j, k = const_v.
505            let fid = ijk[0] + (ni - 1) * ijk[1] + (ni - 1) * (nj - 1) * const_v;
506            (fm.sk_x[fid], fm.sk_y[fid], fm.sk_z[fid])
507        }
508        _ => unreachable!("axis must be 0, 1, or 2"),
509    }
510}
511
512/// Retrieve the face centroid for a boundary face from the pre-computed
513/// face metrics. Parameters match [`boundary_face_area`].
514///
515/// Unlike the area vector, the centroid is a position, so the sign-flip
516/// applied to low-end boundary area vectors does NOT apply here — the
517/// face centroid is the same regardless of which side owns the face.
518fn boundary_face_centroid(
519    axis: usize,
520    const_v: usize,
521    ijk: [usize; 3],
522    blk: &Block,
523    fm: &crate::metrics::FaceMetrics,
524) -> (Float, Float, Float) {
525    let ni = blk.imax;
526    let nj = blk.jmax;
527
528    match axis {
529        0 => {
530            let fid = const_v + ni * ijk[1] + ni * (nj - 1) * ijk[2];
531            (fm.ci_x[fid], fm.ci_y[fid], fm.ci_z[fid])
532        }
533        1 => {
534            let fid = ijk[0] + (ni - 1) * const_v + (ni - 1) * nj * ijk[2];
535            (fm.cj_x[fid], fm.cj_y[fid], fm.cj_z[fid])
536        }
537        2 => {
538            let fid = ijk[0] + (ni - 1) * ijk[1] + (ni - 1) * (nj - 1) * const_v;
539            (fm.ck_x[fid], fm.ck_y[fid], fm.ck_z[fid])
540        }
541        _ => unreachable!("axis must be 0, 1, or 2"),
542    }
543}
544
545/// Build cross-block face data for a single FaceMatch.
546///
547/// Returns a list of
548/// `(owner_global, neighbor_global, area_x, area_y, area_z, cx, cy, cz)`
549/// for each cell pair at the interface — the face centroid is taken from
550/// block1's metrics (same physical point regardless of which side is the
551/// owner).
552///
553/// The area vector is taken from block1's face metrics at the interface,
554/// pointing from block1 (owner) toward block2 (neighbor).
555fn cross_block_face_data(
556    b1: usize,
557    face1: &FaceRecord,
558    blk1: &Block,
559    b2: usize,
560    face2: &FaceRecord,
561    blk2: &Block,
562    graph: &CellGraph,
563    fm1: &crate::metrics::FaceMetrics,
564    orientation: Option<&crate::face_record::Orientation>,
565) -> Vec<(u32, u32, Float, Float, Float, Float, Float, Float)> {
566    let mut result = Vec::new();
567
568    let axis1 = match face1.constant_axis() {
569        Some(a) => a,
570        None => return result,
571    };
572    let axis2 = match face2.constant_axis() {
573        Some(a) => a,
574        None => return result,
575    };
576
577    let f1_bounds = face1.bounds();
578    let f2_bounds = face2.bounds();
579    let f1_const_val = f1_bounds.0[axis1];
580    let f2_const_val = f2_bounds.0[axis2];
581
582    let n_nodes1 = [blk1.imax, blk1.jmax, blk1.kmax];
583    let n_nodes2 = [blk2.imax, blk2.jmax, blk2.kmax];
584
585    let cell1_const = if f1_const_val == 0 {
586        0
587    } else if f1_const_val == n_nodes1[axis1] - 1 {
588        n_nodes1[axis1] - 2
589    } else {
590        return result;
591    };
592    let cell2_const = if f2_const_val == 0 {
593        0
594    } else if f2_const_val == n_nodes2[axis2] - 1 {
595        n_nodes2[axis2] - 2
596    } else {
597        return result;
598    };
599
600    let is_high1 = f1_const_val == n_nodes1[axis1] - 1;
601
602    let var_axes1: Vec<usize> = (0..3).filter(|&a| a != axis1).collect();
603    let var_axes2: Vec<usize> = (0..3).filter(|&a| a != axis2).collect();
604
605    let f1_lo = [face1.i_lo(), face1.j_lo(), face1.k_lo()];
606    let f1_hi = [face1.i_hi(), face1.j_hi(), face1.k_hi()];
607    let f2_lo = [face2.i_lo(), face2.j_lo(), face2.k_lo()];
608    let f2_hi = [face2.i_hi(), face2.j_hi(), face2.k_hi()];
609
610    let n_u1 = f1_hi[var_axes1[0]] - f1_lo[var_axes1[0]];
611    let n_v1 = f1_hi[var_axes1[1]] - f1_lo[var_axes1[1]];
612    let n_u2 = f2_hi[var_axes2[0]] - f2_lo[var_axes2[0]];
613    let n_v2 = f2_hi[var_axes2[1]] - f2_lo[var_axes2[1]];
614
615    if n_u1 == 0 || n_v1 == 0 {
616        return result;
617    }
618
619    // Decode the cell-pair mapping flags. Prefer the cascade-verified
620    // `permutation_index` when present — it is the only reliable
621    // source of truth for cross-axis 32×32 matches where the
622    // extent-shape heuristic below is structurally indeterminate.
623    //
624    // `permutation_index` bit encoding (matches `apply_permutation` in
625    // `verification.rs:117-143`):
626    //   bit 0 → u_reversed
627    //   bit 1 → v_reversed
628    //   bit 2 → swapped (transpose u ↔ v)
629    //
630    // Legacy heuristic fallback (`orientation = None`):
631    //   * `swapped` is inferred from extent-shape mismatch — works for
632    //     non-square faces, but always returns `false` for square N×N.
633    //   * `f2_u_reversed`/`f2_v_reversed` come from the lb>ub flip in
634    //     each axis of FaceRecord.
635    let f2_raw = [
636        [face2.il, face2.ih],
637        [face2.jl, face2.jh],
638        [face2.kl, face2.kh],
639    ];
640    let (swapped, f2_u_reversed, f2_v_reversed) = match orientation {
641        Some(o) => {
642            let pi = o.permutation_index;
643            (
644                (pi & 0b100) != 0, // bit 2: swap
645                (pi & 0b001) != 0, // bit 0: u_reversed
646                (pi & 0b010) != 0, // bit 1: v_reversed
647            )
648        }
649        None => {
650            // Legacy heuristic — kept for callers that haven't run
651            // the cascade verifier (e.g. unit tests with no orientation).
652            let swap = (n_u1 == n_v2)
653                && (n_v1 == n_u2)
654                && !((n_u1 == n_u2) && (n_v1 == n_v2));
655            let u_rev = f2_raw[var_axes2[0]][0] > f2_raw[var_axes2[0]][1];
656            let v_rev = f2_raw[var_axes2[1]][0] > f2_raw[var_axes2[1]][1];
657            (swap, u_rev, v_rev)
658        }
659    };
660
661    let (nci1, ncj1, _) = graph.block_cell_dims[b1];
662    let (nci2, ncj2, _) = graph.block_cell_dims[b2];
663
664    for v in 0..n_v1 {
665        for u in 0..n_u1 {
666            // Block1 cell
667            let mut ijk1 = [0usize; 3];
668            ijk1[axis1] = cell1_const;
669            ijk1[var_axes1[0]] = f1_lo[var_axes1[0]] + u;
670            ijk1[var_axes1[1]] = f1_lo[var_axes1[1]] + v;
671
672            // Block2 cell (with orientation mapping)
673            let (u2, v2) = if swapped { (v, u) } else { (u, v) };
674            let u2_mapped = if f2_u_reversed { n_u2 - 1 - u2 } else { u2 };
675            let v2_mapped = if f2_v_reversed { n_v2 - 1 - v2 } else { v2 };
676
677            let mut ijk2 = [0usize; 3];
678            ijk2[axis2] = cell2_const;
679            ijk2[var_axes2[0]] = f2_lo[var_axes2[0]] + u2_mapped;
680            ijk2[var_axes2[1]] = f2_lo[var_axes2[1]] + v2_mapped;
681
682            let gid1 = graph.block_offset[b1]
683                + cell_index(ijk1[0], ijk1[1], ijk1[2], nci1, ncj1);
684            let gid2 = graph.block_offset[b2]
685                + cell_index(ijk2[0], ijk2[1], ijk2[2], nci2, ncj2);
686
687            // Face area vector from block1's metrics at the interface face.
688            // For the high-side face, the area vector already points in the +axis
689            // direction (toward block2). For the low-side face, we negate.
690            let (mut ax, mut ay, mut az) = boundary_face_area(
691                axis1, f1_const_val, ijk1, blk1, fm1,
692            );
693            if !is_high1 {
694                // Face at low end of block1: outward from block1 is the -axis
695                // direction, which means toward block2. The raw area vector points
696                // in +axis, so we negate it to get the outward direction.
697                ax = -ax;
698                ay = -ay;
699                az = -az;
700            }
701
702            // Face centroid (a position — no sign flip for low-end faces).
703            let (cx, cy, cz) = boundary_face_centroid(
704                axis1, f1_const_val, ijk1, blk1, fm1,
705            );
706
707            result.push((gid1 as u32, gid2 as u32, ax, ay, az, cx, cy, cz));
708        }
709    }
710
711    result
712}
713
714// ---------------------------------------------------------------------------
715// Tests
716// ---------------------------------------------------------------------------
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::block::Block;
722    use crate::face_record::{FaceMatch, FaceRecord};
723
724    /// Build a uniform block spanning [x0, x1] x [y0, y1] x [z0, z1].
725    fn uniform_block(
726        ni: usize, nj: usize, nk: usize,
727        x0: f64, x1: f64, y0: f64, y1: f64, z0: f64, z1: f64,
728    ) -> Block {
729        let n = ni * nj * nk;
730        let mut x = Vec::with_capacity(n);
731        let mut y = Vec::with_capacity(n);
732        let mut z = Vec::with_capacity(n);
733        let dx = if ni > 1 { (x1 - x0) / (ni as f64 - 1.0) } else { 0.0 };
734        let dy = if nj > 1 { (y1 - y0) / (nj as f64 - 1.0) } else { 0.0 };
735        let dz = if nk > 1 { (z1 - z0) / (nk as f64 - 1.0) } else { 0.0 };
736        for k in 0..nk {
737            for j in 0..nj {
738                for i in 0..ni {
739                    x.push(x0 + i as f64 * dx);
740                    y.push(y0 + j as f64 * dy);
741                    z.push(z0 + k as f64 * dz);
742                }
743            }
744        }
745        Block::new(ni, nj, nk, x, y, z)
746    }
747
748    #[test]
749    fn test_single_block_flat_mesh() {
750        // 3x3x3 nodes = 2x2x2 = 8 cells
751        let blk = uniform_block(3, 3, 3, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0);
752
753        // All 6 block faces are outer boundaries
754        let outer_faces = vec![
755            // imin face
756            FaceRecord { block_index: 0, il: 0, jl: 0, kl: 0, ih: 0, jh: 2, kh: 2, id: Some(1), u_physical: None, v_physical: None },
757            // imax face
758            FaceRecord { block_index: 0, il: 2, jl: 0, kl: 0, ih: 2, jh: 2, kh: 2, id: Some(2), u_physical: None, v_physical: None },
759            // jmin face
760            FaceRecord { block_index: 0, il: 0, jl: 0, kl: 0, ih: 2, jh: 0, kh: 2, id: Some(3), u_physical: None, v_physical: None },
761            // jmax face
762            FaceRecord { block_index: 0, il: 0, jl: 2, kl: 0, ih: 2, jh: 2, kh: 2, id: Some(4), u_physical: None, v_physical: None },
763            // kmin face
764            FaceRecord { block_index: 0, il: 0, jl: 0, kl: 0, ih: 2, jh: 2, kh: 0, id: Some(5), u_physical: None, v_physical: None },
765            // kmax face
766            FaceRecord { block_index: 0, il: 0, jl: 0, kl: 2, ih: 2, jh: 2, kh: 2, id: Some(6), u_physical: None, v_physical: None },
767        ];
768
769        let mesh = build_flat_mesh(&[blk], &[], &outer_faces);
770
771        assert_eq!(mesh.n_cells, 8);
772        assert_eq!(mesh.cell_volume.len(), 8);
773
774        // Each cell volume should be 0.125 (unit cube divided into 8)
775        for v in &mesh.cell_volume {
776            assert!((v - 0.125).abs() < 1e-10, "Expected 0.125, got {}", v);
777        }
778
779        // Total volume
780        let total_vol: f64 = mesh.cell_volume.iter().sum();
781        assert!((total_vol - 1.0).abs() < 1e-10);
782
783        // Interior faces: 1 interior I-face per j,k pair (2x2=4) + similarly
784        // for J and K = 4 + 4 + 4 = 12 interior faces
785        // Boundary faces: 4 per outer face x 6 faces = 24
786        // Total: 12 + 24 = 36
787        let n_boundary = mesh.face_neighbor.iter().filter(|&&n| n < 0).count();
788        assert_eq!(n_boundary, 24, "Expected 24 boundary faces, got {}", n_boundary);
789        let n_interior = mesh.n_faces - n_boundary;
790        assert_eq!(n_interior, 12, "Expected 12 interior faces, got {}", n_interior);
791
792        // Stats should not panic
793        let stats = mesh.stats();
794        assert!(stats.contains("Cells:"));
795    }
796
797    #[test]
798    fn test_two_block_flat_mesh() {
799        // Two blocks abutting in x: block0 [0,1]^3, block1 [1,2] x [0,1]^2
800        let blk0 = uniform_block(3, 3, 3, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0);
801        let blk1 = uniform_block(3, 3, 3, 1.0, 2.0, 0.0, 1.0, 0.0, 1.0);
802
803        let fm = FaceMatch {
804            block1: FaceRecord {
805                block_index: 0, il: 2, jl: 0, kl: 0, ih: 2, jh: 2, kh: 2,
806                id: None, u_physical: None, v_physical: None,
807            },
808            block2: FaceRecord {
809                block_index: 1, il: 0, jl: 0, kl: 0, ih: 0, jh: 2, kh: 2,
810                id: None, u_physical: None, v_physical: None,
811            },
812            points: vec![],
813            orientation: None,
814        };
815
816        let mesh = build_flat_mesh(&[blk0, blk1], &[fm], &[]);
817
818        assert_eq!(mesh.n_cells, 16);
819        // Cross-block faces: 2x2 = 4
820        // Verify that some faces have neighbors in the other block
821        let cross_faces: Vec<_> = (0..mesh.n_faces)
822            .filter(|&f| {
823                let o = mesh.face_owner[f] as usize;
824                let n = mesh.face_neighbor[f];
825                if n < 0 { return false; }
826                let n = n as usize;
827                mesh.cell_block_id[o] != mesh.cell_block_id[n]
828            })
829            .collect();
830        assert_eq!(cross_faces.len(), 4, "Expected 4 cross-block faces");
831    }
832}