Skip to main content

plot3d/
verification.rs

1//! Gold-standard verification for connectivity and periodicity.
2//!
3//! # Permutation Matrix Approach
4//!
5//! When two block faces meet at an interface, their parametric (u, v)
6//! coordinate systems may differ — flipped, transposed, or both. Rather
7//! than re-extracting coordinates in every possible traversal order, we:
8//!
9//! 1. Extract both faces as **canonical 2D grids** (ascending index order).
10//! 2. Apply the stored [`PERMUTATION_MATRICES`][perm] entry to face B's grid.
11//! 3. Compare point-by-point within tolerance.
12//!
13//! The 8 pre-computed permutation matrices encode every possible orientation.
14//! The `permutation_index` (0-7) is the only orientation data needed:
15//!
16//! ```text
17//! perm_idx = u_reversed | (v_reversed << 1) | (swapped << 2)
18//! ```
19//!
20//! - **0-3** (in-plane): same constant axis, direction flips only.
21//! - **4-7** (cross-plane): different constant axes, loop order changes.
22//!
23//! [perm]: crate::face_record::PERMUTATION_MATRICES
24//!
25//! # Public API
26//!
27//! - [`extract_canonical_grid`] — extract face points as a 2D grid in ascending order
28//! - [`apply_permutation`] — apply a permutation matrix to a 2D grid
29//! - [`verify_match`] — compare two point arrays within tolerance
30//! - [`try_all_permutations`] — find which permutation makes face B match face A
31//! - [`verify_partial_match`] — count matching points when face B is smaller than face A
32//! - [`determine_plane`] — classify face pair as in-plane or cross-plane
33//! - [`verify_connectivity`] — verify connectivity face matches
34//! - [`verify_periodicity`] — verify periodic face matches with rotation
35//! - [`verify_translational_periodicity`] — verify periodic face matches with translation
36//!
37//! # JSON Export Convention
38//!
39//! When exporting to the **diagonal (lb/ub)** JSON format:
40//!
41//! - **In-plane matches** (perm 0-3): block2's `lb`/`ub` encodes traversal
42//!   direction. `permutation_index` is set to **-1** (direction is fully
43//!   recoverable from the bounds).
44//! - **Cross-plane matches** (perm 4-7): ascending `lb`/`ub` bounds with the
45//!   actual `permutation_index`, since bounds alone cannot encode an axis swap.
46
47use crate::block::Block;
48use crate::block_face_functions::{reduce_blocks, rotate_block};
49use crate::face_record::{
50    FaceMatch, FaceRecord, Orientation, OrientationPlane, PERMUTATION_MATRICES,
51};
52use crate::rotational_periodicity::create_rotation_matrix;
53use crate::utils::compute_min_gcd;
54use crate::Float;
55
56// ── Core helpers: extract, permute, compare ─────────────────────────────
57
58/// Extract face points as a canonical 2D grid (both axes ascending).
59///
60/// Finds the constant axis from the FaceRecord bounds, then extracts
61/// points with the first varying axis as the outer loop (u) and the
62/// second as the inner loop (v), both in ascending order.
63///
64/// Returns `(grid, nu, nv)` where `grid` has layout `grid[u * nv + v]`.
65/// Returns `None` if no constant axis is found (degenerate face).
66pub fn extract_canonical_grid(
67    block: &Block,
68    rec: &FaceRecord,
69) -> Option<(Vec<(Float, Float, Float)>, usize, usize)> {
70    let (raw_lo, raw_hi) = rec.bounds();
71    let imax = [
72        block.imax.saturating_sub(1),
73        block.jmax.saturating_sub(1),
74        block.kmax.saturating_sub(1),
75    ];
76    let lo = [
77        raw_lo[0].min(imax[0]),
78        raw_lo[1].min(imax[1]),
79        raw_lo[2].min(imax[2]),
80    ];
81    let hi = [
82        raw_hi[0].min(imax[0]),
83        raw_hi[1].min(imax[1]),
84        raw_hi[2].min(imax[2]),
85    ];
86
87    let const_dim = rec.constant_axis()?;
88    let varying: Vec<usize> = (0..3).filter(|&d| d != const_dim).collect();
89    let d0 = varying[0]; // u axis
90    let d1 = varying[1]; // v axis
91    let nu = hi[d0] - lo[d0] + 1;
92    let nv = hi[d1] - lo[d1] + 1;
93
94    let mut grid = Vec::with_capacity(nu * nv);
95    for u in 0..nu {
96        for v in 0..nv {
97            let mut idx = [0usize; 3];
98            idx[const_dim] = lo[const_dim];
99            idx[d0] = lo[d0] + u;
100            idx[d1] = lo[d1] + v;
101            grid.push(block.xyz(idx[0], idx[1], idx[2]));
102        }
103    }
104
105    Some((grid, nu, nv))
106}
107
108/// Apply a pre-computed permutation matrix to a 2D grid.
109///
110/// Uses [`PERMUTATION_MATRICES`] to transform `grid_b`'s (u, v) layout
111/// to match `grid_a`'s layout. The matrix is looked up by `perm_idx` (0-7),
112/// not recalculated.
113///
114/// Bit encoding: `perm_idx = u_reversed | (v_reversed << 1) | (swapped << 2)`
115///
116/// Returns `(permuted_grid, out_nu, out_nv)`.
117pub fn apply_permutation(
118    grid: &[(Float, Float, Float)],
119    nu: usize,
120    nv: usize,
121    perm_idx: u8,
122) -> (Vec<(Float, Float, Float)>, usize, usize) {
123    let _mat = PERMUTATION_MATRICES[perm_idx as usize];
124
125    let u_rev = perm_idx & 1 != 0;
126    let v_rev = perm_idx & 2 != 0;
127    let swap = perm_idx & 4 != 0;
128
129    let (out_nu, out_nv) = if swap { (nv, nu) } else { (nu, nv) };
130
131    let mut result = Vec::with_capacity(out_nu * out_nv);
132    for ou in 0..out_nu {
133        for ov in 0..out_nv {
134            // Map output (ou, ov) back to canonical grid indices (gu, gv)
135            let (gu, gv) = if swap { (ov, ou) } else { (ou, ov) };
136            let gu = if u_rev { nu - 1 - gu } else { gu };
137            let gv = if v_rev { nv - 1 - gv } else { gv };
138            result.push(grid[gu * nv + gv]);
139        }
140    }
141
142    (result, out_nu, out_nv)
143}
144
145/// Compare two point arrays within tolerance.
146///
147/// Returns `true` if all corresponding points are within `tol` Euclidean
148/// distance. Returns `false` if lengths differ or any point exceeds tolerance.
149pub fn verify_match(
150    pts_a: &[(Float, Float, Float)],
151    pts_b: &[(Float, Float, Float)],
152    tol: Float,
153) -> bool {
154    if pts_a.len() != pts_b.len() {
155        return false;
156    }
157    let tol2 = tol * tol;
158    for (a, b) in pts_a.iter().zip(pts_b.iter()) {
159        let d2 = (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2) + (a.2 - b.2).powi(2);
160        if d2 > tol2 {
161            return false;
162        }
163    }
164    true
165}
166
167/// Compute the maximum Euclidean distance between corresponding points.
168///
169/// Returns `Float::MAX` if the arrays differ in length.
170fn max_point_distance(
171    pts_a: &[(Float, Float, Float)],
172    pts_b: &[(Float, Float, Float)],
173) -> Float {
174    if pts_a.len() != pts_b.len() {
175        return Float::MAX;
176    }
177    let mut max_d2: Float = 0.0;
178    for (a, b) in pts_a.iter().zip(pts_b.iter()) {
179        let d2 = (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2) + (a.2 - b.2).powi(2);
180        if d2 > max_d2 {
181            max_d2 = d2;
182        }
183    }
184    max_d2.sqrt()
185}
186
187/// Count how many points of face B (small, after permutation) match face A (large).
188///
189/// Face A is the large face, face B is the small face. We apply the permutation
190/// to face B and check how many of B's transformed points exist within face A
191/// (within tolerance). If all of face B's points match, the larger face A
192/// should be split.
193///
194/// Returns `(match_count, total_b_points)`.
195pub fn verify_partial_match(
196    grid_a: &[(Float, Float, Float)],
197    grid_b_permuted: &[(Float, Float, Float)],
198    tol: Float,
199) -> (usize, usize) {
200    let tol2 = tol * tol;
201    let mut count = 0;
202    for b in grid_b_permuted {
203        for a in grid_a {
204            let d2 = (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2) + (a.2 - b.2).powi(2);
205            if d2 <= tol2 {
206                count += 1;
207                break;
208            }
209        }
210    }
211    (count, grid_b_permuted.len())
212}
213
214/// Determine if two faces are in-plane (same constant axis) or cross-plane.
215pub fn determine_plane(rec_a: &FaceRecord, rec_b: &FaceRecord) -> OrientationPlane {
216    if rec_a.constant_axis() == rec_b.constant_axis() {
217        OrientationPlane::InPlane
218    } else {
219        OrientationPlane::CrossPlane
220    }
221}
222
223// ── Permutation search ──────────────────────────────────────────────────
224
225/// Try all 8 permutation matrices on `grid_b` to find one that matches `grid_a`.
226///
227/// For each permutation index 0..8:
228/// 1. Apply the permutation to `grid_b` via [`apply_permutation`].
229/// 2. Check output shape matches `grid_a`'s shape.
230/// 3. Compare point-by-point via [`verify_match`].
231///
232/// Returns `Some(perm_idx)` on the first match, or `None` if no permutation works.
233pub fn try_all_permutations(
234    grid_a: &[(Float, Float, Float)],
235    nu_a: usize,
236    nv_a: usize,
237    grid_b: &[(Float, Float, Float)],
238    nu_b: usize,
239    nv_b: usize,
240    tol: Float,
241) -> Option<u8> {
242    for perm_idx in 0u8..8 {
243        let (permuted, out_nu, out_nv) = apply_permutation(grid_b, nu_b, nv_b, perm_idx);
244
245        // Shape check — this is the key fix for cross-plane matches
246        if out_nu != nu_a || out_nv != nv_a {
247            continue;
248        }
249
250        if verify_match(grid_a, &permuted, tol) {
251            return Some(perm_idx);
252        }
253    }
254    None
255}
256
257// ── Connectivity verification ───────────────────────────────────────────
258
259/// Verify connectivity face matches using permutation matrices.
260///
261/// GCD-reduce blocks and scale face-match indices to match.
262fn prepare_reduced(blocks: &[Block], face_matches: &[FaceMatch]) -> (Vec<Block>, Vec<FaceMatch>) {
263    let gcd_to_use = compute_min_gcd(blocks);
264    let reduced_blocks = reduce_blocks(blocks, gcd_to_use);
265    let scaled_matches: Vec<FaceMatch> = face_matches
266        .iter()
267        .map(|fm| {
268            let mut sfm = fm.clone();
269            sfm.divide_indices(gcd_to_use);
270            sfm
271        })
272        .collect();
273    (reduced_blocks, scaled_matches)
274}
275
276/// For each face match:
277/// 1. GCD-reduce blocks and scale indices.
278/// 2. Extract both faces as canonical 2D grids.
279/// 3. Try stored `permutation_index` first (if available).
280/// 4. Fall back to [`try_all_permutations`] if needed.
281/// 5. On success, update the `FaceMatch` with the correct `permutation_index`.
282///
283/// # Returns
284/// `(verified, mismatched)` vectors of face matches.
285pub fn verify_connectivity(
286    blocks: &[Block],
287    face_matches: &[FaceMatch],
288    tol: Float,
289) -> (Vec<FaceMatch>, Vec<FaceMatch>) {
290    let (reduced_blocks, scaled_matches) = prepare_reduced(blocks, face_matches);
291
292    let mut verified = Vec::new();
293    let mut mismatched = Vec::new();
294
295    for (idx, sfm) in scaled_matches.iter().enumerate() {
296        let b1 = &sfm.block1;
297        let b2 = &sfm.block2;
298        let b1_idx = b1.block_index;
299        let b2_idx = b2.block_index;
300
301        if b1_idx >= reduced_blocks.len() || b2_idx >= reduced_blocks.len() {
302            mismatched.push(face_matches[idx].clone());
303            continue;
304        }
305
306        let block1 = &reduced_blocks[b1_idx];
307        let block2 = &reduced_blocks[b2_idx];
308
309        // Extract canonical grids
310        let grid_a = match extract_canonical_grid(block1, b1) {
311            Some(g) => g,
312            None => {
313                mismatched.push(face_matches[idx].clone());
314                continue;
315            }
316        };
317        let grid_b = match extract_canonical_grid(block2, b2) {
318            Some(g) => g,
319            None => {
320                mismatched.push(face_matches[idx].clone());
321                continue;
322            }
323        };
324
325        let (pts_a, nu_a, nv_a) = grid_a;
326        let (pts_b, nu_b, nv_b) = grid_b;
327
328        // Try stored permutation_index first (if available)
329        let stored_perm = sfm.orientation.as_ref().map(|o| o.permutation_index);
330        if let Some(perm_idx) = stored_perm {
331            let (permuted, out_nu, out_nv) = apply_permutation(&pts_b, nu_b, nv_b, perm_idx);
332            if out_nu == nu_a && out_nv == nv_a && verify_match(&pts_a, &permuted, tol) {
333                verified.push(face_matches[idx].clone());
334                continue;
335            }
336        }
337
338        // Fall back: try all 8 permutations
339        if let Some(perm_idx) = try_all_permutations(&pts_a, nu_a, nv_a, &pts_b, nu_b, nv_b, tol) {
340            let mut corrected = face_matches[idx].clone();
341            let plane = determine_plane(b1, b2);
342            corrected.orientation = Some(Orientation {
343                permutation_index: perm_idx,
344                plane,
345            });
346            verified.push(corrected);
347        } else {
348            // Diagnostic dump gated on env var: in a cascade pipeline
349            // (load.rs::load_mesh) this verifier is the FIRST stage —
350            // matches that need translational or rotational verification
351            // legitimately fail here and fall through. Routine misses
352            // would otherwise spam stderr (~1k lines on CMC009 rf=1).
353            // Set `PLOT3D_RS_VERIFY_CONNECTIVITY_VERBOSE=1` to debug.
354            if std::env::var("PLOT3D_RS_VERIFY_CONNECTIVITY_VERBOSE").as_deref() == Ok("1") {
355                let orig = &face_matches[idx];
356                let ca1 = b1.constant_axis();
357                let ca2 = b2.constant_axis();
358                let axis_label = |a: Option<usize>| match a {
359                    Some(0) => "I", Some(1) => "J", Some(2) => "K", _ => "?"
360                };
361                let cross_tag = if ca1 != ca2 { "CROSS-AXIS" } else { "SAME-AXIS" };
362                let mut best_dist: Float = Float::MAX;
363                for p in 0u8..8 {
364                    let (permuted, out_nu, out_nv) = apply_permutation(&pts_b, nu_b, nv_b, p);
365                    if out_nu != nu_a || out_nv != nv_a { continue; }
366                    let d = max_point_distance(&pts_a, &permuted);
367                    if d < best_dist { best_dist = d; }
368                }
369                eprintln!("verify_connectivity: MISMATCH at index {} [{}]", idx, cross_tag);
370                eprintln!(
371                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
372                    orig.block1.block_index,
373                    orig.block1.i_lo(), orig.block1.j_lo(), orig.block1.k_lo(),
374                    orig.block1.i_hi(), orig.block1.j_hi(), orig.block1.k_hi(),
375                    axis_label(ca1)
376                );
377                eprintln!(
378                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
379                    orig.block2.block_index,
380                    orig.block2.i_lo(), orig.block2.j_lo(), orig.block2.k_lo(),
381                    orig.block2.i_hi(), orig.block2.j_hi(), orig.block2.k_hi(),
382                    axis_label(ca2)
383                );
384                eprintln!("  grid_a: {}x{}, grid_b: {}x{}, best_dist: {:.6e}", nu_a, nv_a, nu_b, nv_b, best_dist);
385            }
386            mismatched.push(face_matches[idx].clone());
387        }
388    }
389
390    (verified, mismatched)
391}
392
393/// Verify periodic face matches using permutation matrices with rotation.
394///
395/// For each face match, rotates block1 by +/- theta and then uses the
396/// same canonical grid + permutation approach as [`verify_connectivity`].
397///
398/// # Arguments
399/// * `theta` - rotation angle in **radians**
400///
401/// # Returns
402/// `(verified, mismatched)` vectors of face matches.
403pub fn verify_periodicity(
404    blocks: &[Block],
405    face_matches: &[FaceMatch],
406    theta: Float,
407    rotation_axis: char,
408    tol: Float,
409) -> (Vec<FaceMatch>, Vec<FaceMatch>) {
410    let (reduced_blocks, scaled_matches) = prepare_reduced(blocks, face_matches);
411
412    let rotation_matrix_pos = create_rotation_matrix(theta, rotation_axis);
413    let rotation_matrix_neg = create_rotation_matrix(-theta, rotation_axis);
414
415    let rotated_blocks_pos: Vec<Block> = reduced_blocks
416        .iter()
417        .map(|b| rotate_block(b, rotation_matrix_pos))
418        .collect();
419    let rotated_blocks_neg: Vec<Block> = reduced_blocks
420        .iter()
421        .map(|b| rotate_block(b, rotation_matrix_neg))
422        .collect();
423
424    let mut verified = Vec::new();
425    let mut mismatched = Vec::new();
426
427    for (idx, sfm) in scaled_matches.iter().enumerate() {
428        let b1 = &sfm.block1;
429        let b2 = &sfm.block2;
430        let b1_idx = b1.block_index;
431        let b2_idx = b2.block_index;
432
433        if b1_idx >= reduced_blocks.len() || b2_idx >= reduced_blocks.len() {
434            mismatched.push(face_matches[idx].clone());
435            continue;
436        }
437
438        let block2 = &reduced_blocks[b2_idx];
439
440        // Extract face B's canonical grid (unrotated)
441        let grid_b = match extract_canonical_grid(block2, b2) {
442            Some(g) => g,
443            None => {
444                mismatched.push(face_matches[idx].clone());
445                continue;
446            }
447        };
448        let (pts_b, nu_b, nv_b) = grid_b;
449
450        let mut found = false;
451        let mut best_dist: Float = Float::MAX;
452        let mut best_dims: Option<(usize, usize, usize, usize)> = None;
453
454        // Try +theta rotation first, then -theta
455        for rotated_blocks in [&rotated_blocks_pos, &rotated_blocks_neg] {
456            if found {
457                break;
458            }
459
460            let block1_rotated = &rotated_blocks[b1_idx];
461
462            // Extract face A's canonical grid (from rotated block)
463            let grid_a = match extract_canonical_grid(block1_rotated, b1) {
464                Some(g) => g,
465                None => continue,
466            };
467            let (pts_a, nu_a, nv_a) = grid_a;
468
469            // Track grid dimensions for diagnostics
470            if best_dims.is_none() {
471                best_dims = Some((nu_a, nv_a, nu_b, nv_b));
472            }
473
474            // Try stored permutation_index first
475            let stored_perm = sfm.orientation.as_ref().map(|o| o.permutation_index);
476            if let Some(perm_idx) = stored_perm {
477                let (permuted, out_nu, out_nv) = apply_permutation(&pts_b, nu_b, nv_b, perm_idx);
478                if out_nu == nu_a && out_nv == nv_a && verify_match(&pts_a, &permuted, tol) {
479                    verified.push(face_matches[idx].clone());
480                    found = true;
481                    break;
482                }
483            }
484
485            // Fall back: try all 8 permutations
486            if let Some(perm_idx) =
487                try_all_permutations(&pts_a, nu_a, nv_a, &pts_b, nu_b, nv_b, tol)
488            {
489                let mut corrected = face_matches[idx].clone();
490                let plane = determine_plane(b1, b2);
491                corrected.orientation = Some(Orientation {
492                    permutation_index: perm_idx,
493                    plane,
494                });
495                verified.push(corrected);
496                found = true;
497                break;
498            }
499
500            // Track best distance for diagnostics
501            for p in 0u8..8 {
502                let (permuted, out_nu, out_nv) = apply_permutation(&pts_b, nu_b, nv_b, p);
503                if out_nu != nu_a || out_nv != nv_a { continue; }
504                let d = max_point_distance(&pts_a, &permuted);
505                if d < best_dist { best_dist = d; }
506            }
507        }
508
509        if !found {
510            // Diagnostic gated on env var (cascade misses are routine).
511            // Set `PLOT3D_RS_VERIFY_PERIODICITY_VERBOSE=1` to debug.
512            if std::env::var("PLOT3D_RS_VERIFY_PERIODICITY_VERBOSE").as_deref() == Ok("1") {
513                let orig = &face_matches[idx];
514                let ca1 = b1.constant_axis();
515                let ca2 = b2.constant_axis();
516                let axis_label = |a: Option<usize>| match a {
517                    Some(0) => "I", Some(1) => "J", Some(2) => "K", _ => "?"
518                };
519                let cross_tag = if ca1 != ca2 { "CROSS-AXIS" } else { "SAME-AXIS" };
520                eprintln!("verify_periodicity: MISMATCH at index {} [{}]", idx, cross_tag);
521                eprintln!(
522                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
523                    orig.block1.block_index,
524                    orig.block1.i_lo(), orig.block1.j_lo(), orig.block1.k_lo(),
525                    orig.block1.i_hi(), orig.block1.j_hi(), orig.block1.k_hi(),
526                    axis_label(ca1)
527                );
528                eprintln!(
529                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
530                    orig.block2.block_index,
531                    orig.block2.i_lo(), orig.block2.j_lo(), orig.block2.k_lo(),
532                    orig.block2.i_hi(), orig.block2.j_hi(), orig.block2.k_hi(),
533                    axis_label(ca2)
534                );
535                if let Some((nua, nva, nub, nvb)) = best_dims {
536                    eprintln!("  grid_a: {}x{}, grid_b: {}x{}, best_dist: {:.6e}", nua, nva, nub, nvb, best_dist);
537                }
538            }
539            let _ = best_dist; let _ = best_dims;
540            mismatched.push(face_matches[idx].clone());
541        }
542    }
543
544    (verified, mismatched)
545}
546
547/// Verify face-match list against the grid under TRANSLATIONAL
548/// periodicity (e.g. blade-pitch in y, span height in z).
549///
550/// Mirror of [`verify_periodicity`] but uses **translation** instead
551/// of **rotation**: shifts every block by `±delta` along `axis`
552/// (`'x' | 'y' | 'z'`), then for each face_match tries all 8
553/// permutations against the shifted-block grid.
554///
555/// Use this for face_matches that are translationally periodic — i.e.,
556/// the two faces don't physically coincide in the original mesh, but
557/// they DO coincide once one block is translated by the periodicity
558/// vector. CMC009 has translational periodicity in **both Y (pitch)**
559/// and **Z (span height)**; call once for each direction in a cascading
560/// pipeline:
561///
562/// ```ignore
563/// let (verified_y, leftover_after_y) =
564///     verify_translational_periodicity(&blocks, &leftover_from_verify_connectivity, None, 'y', 1.0e-6);
565/// let (verified_z, still_unverified) =
566///     verify_translational_periodicity(&blocks, &leftover_after_y, None, 'z', 1.0e-6);
567/// ```
568///
569/// `delta` is the magnitude of the translation along `axis`.
570///
571/// **`None` triggers PER-MATCH auto-detect**: for each face_match, the
572/// shift is computed as the difference between the centroids of face A
573/// and face B projected onto `axis`. This is the geometrically-correct
574/// per-match displacement and works for both:
575///   * **Same-block self-loops** (e.g. block 589's k=0 ↔ k=4 face,
576///     where Δ_z = block-z-extent)
577///   * **Cross-block translational pitch matches** (where Δ is the
578///     blade pitch in y or the span height in z)
579///
580/// A globally-fixed `Some(delta)` is supported for callers that want
581/// a single mesh-wide pitch — but the per-match auto-detect is
582/// generally more robust because it adapts to whatever shift each
583/// individual face_match actually requires.
584///
585/// # Returns
586///
587/// `(verified, mismatched)` — verified face_matches have their
588/// [`FaceMatch::orientation`] populated with the winning
589/// `permutation_index` and the appropriate
590/// [`OrientationPlane`] discriminator (`InPlane` for same-axis matches,
591/// `CrossPlane` for axis-swapped). Mismatched face_matches are
592/// returned for the caller to retry against another verifier
593/// (e.g., the rotational [`verify_periodicity`]).
594pub fn verify_translational_periodicity(
595    blocks: &[Block],
596    face_matches: &[FaceMatch],
597    delta: Option<Float>,
598    axis: char,
599    tol: Float,
600) -> (Vec<FaceMatch>, Vec<FaceMatch>) {
601    let (reduced_blocks, scaled_matches) = prepare_reduced(blocks, face_matches);
602
603    let axis_idx = match axis {
604        'x' | 'X' => 0usize,
605        'y' | 'Y' => 1usize,
606        'z' | 'Z' => 2usize,
607        _ => panic!("verify_translational_periodicity: invalid axis {:?}", axis),
608    };
609
610    // Helper: face centroid along `axis_idx`. Walks the FaceRecord's
611    // (lb..ub) range on the parent block and averages the coordinate.
612    // Uses `extract_canonical_grid` indirectly by going through the
613    // block's corner coords — cheap and avoids re-extracting the full
614    // canonical grid just for centroid computation.
615    let face_axis_centroid = |block: &Block, rec: &FaceRecord| -> Float {
616        // FaceRecord has `i_lo, i_hi, j_lo, j_hi, k_lo, k_hi` accessors.
617        // Walk the inclusive range and average. Reduced blocks have
618        // small node counts so this is cheap.
619        let (il, jh, kl) = (rec.i_lo(), rec.j_lo(), rec.k_lo());
620        let (ih, jl, kh) = (rec.i_hi(), rec.j_hi(), rec.k_hi());
621        // FaceRecord may have lb > ub for direction-flipped faces;
622        // normalise to ascending for the centroid walk.
623        let (i0, i1) = if il <= ih { (il, ih) } else { (ih, il) };
624        let (j0, j1) = if jl <= jh { (jl, jh) } else { (jh, jl) };
625        let (k0, k1) = if kl <= kh { (kl, kh) } else { (kh, kl) };
626        let mut sum: Float = 0.0;
627        let mut n: usize = 0;
628        for k in k0..=k1 {
629            for j in j0..=j1 {
630                for i in i0..=i1 {
631                    let (x, y, z) = block.xyz(i, j, k);
632                    let v = match axis_idx {
633                        0 => x,
634                        1 => y,
635                        _ => z,
636                    };
637                    sum += v;
638                    n += 1;
639                }
640            }
641        }
642        sum / (n.max(1) as Float)
643    };
644
645    let mut verified = Vec::new();
646    let mut mismatched = Vec::new();
647
648    for (idx, sfm) in scaled_matches.iter().enumerate() {
649        let b1 = &sfm.block1;
650        let b2 = &sfm.block2;
651        let b1_idx = b1.block_index;
652        let b2_idx = b2.block_index;
653
654        if b1_idx >= reduced_blocks.len() || b2_idx >= reduced_blocks.len() {
655            mismatched.push(face_matches[idx].clone());
656            continue;
657        }
658
659        let block1 = &reduced_blocks[b1_idx];
660        let block2 = &reduced_blocks[b2_idx];
661
662        // Per-match Δ: when caller didn't pin a global delta, compute
663        // the geometrically-required shift directly from the centroids
664        // of face A vs face B (projected onto the requested axis).
665        // This handles same-block self-loops and cross-block pitch
666        // matches uniformly.
667        let delta_axis = match delta {
668            Some(d) => d,
669            None => {
670                let c1 = face_axis_centroid(block1, b1);
671                let c2 = face_axis_centroid(block2, b2);
672                // We want to shift block 1 onto block 2: Δ = c2 - c1.
673                // The match loop below tries both +Δ and -Δ, so the
674                // sign is irrelevant — store the absolute value.
675                (c2 - c1).abs()
676            }
677        };
678        // Skip degenerate Δ (≈0): means the two faces are already
679        // approximately coincident along this axis — they wouldn't
680        // need a translational verifier; let them fall through.
681        if delta_axis.abs() < tol {
682            mismatched.push(face_matches[idx].clone());
683            continue;
684        }
685        let block1_shifted_pos = block1.shifted(delta_axis, axis);
686        let block1_shifted_neg = block1.shifted(-delta_axis, axis);
687
688        // Face B's canonical grid (un-shifted).
689        let grid_b = match extract_canonical_grid(block2, b2) {
690            Some(g) => g,
691            None => {
692                mismatched.push(face_matches[idx].clone());
693                continue;
694            }
695        };
696        let (pts_b, nu_b, nv_b) = grid_b;
697
698        let mut found = false;
699        let mut best_dist: Float = Float::MAX;
700        let mut best_dims: Option<(usize, usize, usize, usize)> = None;
701
702        // Try +delta translation first, then -delta.
703        for block1_shifted in [&block1_shifted_pos, &block1_shifted_neg] {
704            if found {
705                break;
706            }
707
708
709            let grid_a = match extract_canonical_grid(block1_shifted, b1) {
710                Some(g) => g,
711                None => continue,
712            };
713            let (pts_a, nu_a, nv_a) = grid_a;
714
715            if best_dims.is_none() {
716                best_dims = Some((nu_a, nv_a, nu_b, nv_b));
717            }
718
719            // Try stored permutation_index first (fast-path for
720            // already-verified matches).
721            let stored_perm = sfm.orientation.as_ref().map(|o| o.permutation_index);
722            if let Some(perm_idx) = stored_perm {
723                let (permuted, out_nu, out_nv) =
724                    apply_permutation(&pts_b, nu_b, nv_b, perm_idx);
725                if out_nu == nu_a && out_nv == nv_a && verify_match(&pts_a, &permuted, tol) {
726                    verified.push(face_matches[idx].clone());
727                    found = true;
728                    break;
729                }
730            }
731
732            // Fall back: try all 8 permutations.
733            if let Some(perm_idx) =
734                try_all_permutations(&pts_a, nu_a, nv_a, &pts_b, nu_b, nv_b, tol)
735            {
736                let mut corrected = face_matches[idx].clone();
737                let plane = determine_plane(b1, b2);
738                corrected.orientation = Some(Orientation {
739                    permutation_index: perm_idx,
740                    plane,
741                });
742                verified.push(corrected);
743                found = true;
744                break;
745            }
746
747            // Diagnostic: track best distance across all 8 perms.
748            for p in 0u8..8 {
749                let (permuted, out_nu, out_nv) =
750                    apply_permutation(&pts_b, nu_b, nv_b, p);
751                if out_nu != nu_a || out_nv != nv_a {
752                    continue;
753                }
754                let d = max_point_distance(&pts_a, &permuted);
755                if d < best_dist {
756                    best_dist = d;
757                }
758            }
759        }
760
761        if !found {
762            // Quiet on miss — caller will retry against the next
763            // verifier in the cascade. Keep diagnostics behind an
764            // env var to avoid spamming production runs that legitimately
765            // route some matches to a different verifier.
766            if std::env::var("PLOT3D_RS_VERIFY_TRANSLATIONAL_VERBOSE").as_deref() == Ok("1") {
767                let orig = &face_matches[idx];
768                let ca1 = b1.constant_axis();
769                let ca2 = b2.constant_axis();
770                let axis_label = |a: Option<usize>| match a {
771                    Some(0) => "I",
772                    Some(1) => "J",
773                    Some(2) => "K",
774                    _ => "?",
775                };
776                let cross_tag = if ca1 != ca2 { "CROSS-AXIS" } else { "SAME-AXIS" };
777                eprintln!(
778                    "verify_translational_periodicity[{}, Δ_per_match={:+.3e}]: \
779                     MISMATCH at index {} [{}]",
780                    axis, delta_axis, idx, cross_tag,
781                );
782                eprintln!(
783                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
784                    orig.block1.block_index,
785                    orig.block1.i_lo(), orig.block1.j_lo(), orig.block1.k_lo(),
786                    orig.block1.i_hi(), orig.block1.j_hi(), orig.block1.k_hi(),
787                    axis_label(ca1),
788                );
789                eprintln!(
790                    "  block {}: lo=({},{},{}) hi=({},{},{}) const={}",
791                    orig.block2.block_index,
792                    orig.block2.i_lo(), orig.block2.j_lo(), orig.block2.k_lo(),
793                    orig.block2.i_hi(), orig.block2.j_hi(), orig.block2.k_hi(),
794                    axis_label(ca2),
795                );
796                if let Some((nua, nva, nub, nvb)) = best_dims {
797                    eprintln!(
798                        "  grid_a: {}x{}, grid_b: {}x{}, best_dist: {:.6e}",
799                        nua, nva, nub, nvb, best_dist,
800                    );
801                }
802            }
803            // Suppress unused warning when env var is absent.
804            let _ = best_dims;
805            mismatched.push(face_matches[idx].clone());
806        }
807    }
808
809    (verified, mismatched)
810}