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}