Skip to main content

plot3d/
connectivity.rs

1//! Block-to-block face connectivity detection.
2//!
3//! The core data types ([`FaceRecord`], [`FaceMatch`], [`MatchPoint`],
4//! [`Orientation`]) live in [`crate::face_record`]. This module provides the
5//! algorithms that populate them.
6//!
7//! # Three-Phase Connectivity Algorithm
8//!
9//! The [`connectivity_fast`] function detects face matches in three phases:
10//!
11//! **Phase 1 -- Full-face matching using canonical grids + permutation matrices**
12//!
13//! For every pair of outer faces, corner comparison is used as a quick
14//! pre-filter. Candidate matches are then verified by extracting both
15//! faces as canonical 2D grids (ascending index order) and trying all 8
16//! [`PERMUTATION_MATRICES`] on face B. When `Face_B * permutation == Face_A`
17//! within tolerance, the match is confirmed and the winning `permutation_index`
18//! is stored in [`FaceMatch::orientation`].
19//!
20//! **Phase 2 -- Partial / split-face node-by-node matching**
21//!
22//! Remaining unmatched faces are tested for partial overlap via
23//! [`get_face_intersection`]. When a partial match is found, both faces are
24//! split along the intersection boundary. The matched sub-faces produce
25//! [`FaceMatch`] records, while the leftover remnants are fed back into the
26//! pool for subsequent iterations. This loop continues until no new matches
27//! are discovered during a full pass.
28//!
29//! **Phase 3 -- Fresh-face validation with all-node AABB pre-checks**
30//!
31//! After Phase 2 converges, any remaining unmatched faces may still have
32//! partial overlaps with faces that were *already matched* in Phases 1 or 2.
33//! Phase 3 re-examines these by computing axis-aligned bounding boxes
34//! (AABBs) using **all face nodes** (not just corners), then testing AABB
35//! overlap before calling `get_face_intersection`. This catches edge cases
36//! where two corners of a sub-face lie outside the bounding diagonal of
37//! a candidate face, which the 2-corner AABB would miss.
38//!
39//! # Post-processing
40//!
41//! After matching, [`align_face_orientations`] can optionally correct the
42//! diagonal corners of `block2` in each [`FaceMatch`] so that they encode
43//! the detected orientation. This embeds the permutation directly into the
44//! `il/jl/kl` and `ih/jh/kh` fields, which is the format expected by
45//! solvers that use the GridPro/GlennHT diagonal convention.
46//!
47//! # Tolerance
48//!
49//! The default spatial tolerance used for vertex comparisons is
50//! [`DEFAULT_TOL`] (1e-6). Both [`connectivity`] and [`connectivity_fast`]
51//! accept an explicit tolerance parameter to override this default.
52//!
53//! # Verification
54//!
55//! Use [`verify_connectivity`] after running connectivity to confirm that
56//! all matched face pairs have coincident nodes.
57
58use std::collections::{HashMap, HashSet};
59
60use indicatif::{ProgressBar, ProgressStyle};
61
62use crate::{
63    block::Block,
64    block_face_functions::{
65        create_face_from_diagonals, get_outer_faces, split_face, Face,
66    },
67    face_record::{match_point_bounds, FaceKey, FaceMatch, FaceRecord, MatchPoint, Orientation},
68    verification::{determine_plane, extract_canonical_grid, try_all_permutations},
69    Float,
70};
71
72const DEFAULT_TOL: Float = 1e-6;
73
74/// Structured-grid node on a face, capturing indices and XYZ coordinate.
75#[derive(Clone, Debug)]
76struct FaceNode {
77    i: usize,
78    j: usize,
79    k: usize,
80    coord: [Float; 3],
81}
82
83/// Enumerate all nodes that belong to `face` on `block`.
84///
85/// # Arguments
86/// * `face` - Face whose nodes should be sampled.
87/// * `block` - Parent block providing Cartesian coordinates.
88///
89/// # Returns
90/// Vector of [`FaceNode`] containing structured indices `(i, j, k)` and the
91/// corresponding XYZ coordinate.
92fn face_nodes(face: &Face, block: &Block) -> Vec<FaceNode> {
93    let mut nodes = Vec::new();
94    let i_vals: Vec<usize> = if face.imin() == face.imax() {
95        vec![face.imin()]
96    } else {
97        (face.imin()..=face.imax()).collect()
98    };
99    let j_vals: Vec<usize> = if face.jmin() == face.jmax() {
100        vec![face.jmin()]
101    } else {
102        (face.jmin()..=face.jmax()).collect()
103    };
104    let k_vals: Vec<usize> = if face.kmin() == face.kmax() {
105        vec![face.kmin()]
106    } else {
107        (face.kmin()..=face.kmax()).collect()
108    };
109    for &i in &i_vals {
110        for &j in &j_vals {
111            for &k in &k_vals {
112                if !(i < block.imax && j < block.jmax && k < block.kmax) {
113                    continue;
114                }
115                let (x, y, z) = block.xyz(i, j, k);
116                nodes.push(FaceNode {
117                    i,
118                    j,
119                    k,
120                    coord: [x, y, z],
121                });
122            }
123        }
124    }
125    nodes
126}
127
128/// Locate the node whose coordinate is within `tol` of `target`.
129///
130/// Returns the first node that meets the tolerance, preferring the closest
131/// distance. When no node matches, `None` is returned.
132fn find_closest_node(
133    nodes: &[FaceNode],
134    target: [Float; 3],
135    tol: Float,
136) -> Option<&FaceNode> {
137    let mut best: Option<(&FaceNode, Float)> = None;
138    for node in nodes {
139        let dx = node.coord[0] - target[0];
140        let dy = node.coord[1] - target[1];
141        let dz = node.coord[2] - target[2];
142        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
143        if dist <= tol {
144            match best {
145                Some((_, best_dist)) if dist >= best_dist => {}
146                _ => best = Some((node, dist)),
147            }
148        }
149    }
150    best.map(|(node, _)| node)
151}
152
153/// Check whether the coincident nodes degenerate to an edge contact.
154fn is_edge(points: &[MatchPoint]) -> bool {
155    if points.is_empty() {
156        return false;
157    }
158    let (i_lo, i_hi, j_lo, j_hi, k_lo, k_hi) = match_point_bounds(points, true);
159    let const_count =
160        usize::from(i_lo == i_hi) + usize::from(j_lo == j_hi) + usize::from(k_lo == k_hi);
161    const_count >= 2
162}
163
164/// Filter matches so the provided key advances monotonically by 1.
165///
166/// When there are exactly 2 unique values, they are always kept regardless of
167/// gap size. This handles the case where a small face (e.g. 2 nodes wide after
168/// GCD reduction) matches a large face — the matching indices on the large face
169/// may span a wide gap (e.g. [0, 113]) but are still a valid match. The
170/// `is_edge()` check upstream has already verified this isn't a degenerate edge.
171fn filter_block_increasing(
172    points: &[MatchPoint],
173    key: fn(&MatchPoint) -> usize,
174) -> Vec<MatchPoint> {
175    if points.is_empty() {
176        return Vec::new();
177    }
178    let mut unique_vals: Vec<usize> = points.iter().map(key).collect();
179    unique_vals.sort_unstable();
180    unique_vals.dedup();
181    if unique_vals.len() <= 1 {
182        return Vec::new();
183    }
184    // With only 2 unique values, contiguity is trivially satisfied — keep all.
185    if unique_vals.len() == 2 {
186        return points.to_vec();
187    }
188    let mut keep: HashSet<usize> = HashSet::new();
189    for window in unique_vals.windows(2) {
190        if window[1] == window[0] + 1 {
191            keep.insert(window[0]);
192            keep.insert(window[1]);
193        }
194    }
195    points
196        .iter()
197        .filter(|p| keep.contains(&key(p)))
198        .cloned()
199        .collect()
200}
201
202/// Enforce monotonic progression along the non-constant axes of each face.
203fn apply_axis_filters(points: Vec<MatchPoint>, face1: &Face, face2: &Face) -> Vec<MatchPoint> {
204    let mut filtered = points;
205    match face1.const_axis() {
206        Some(crate::block_face_functions::FaceAxis::I) => {
207            filtered = filter_block_increasing(&filtered, |p| p.j1);
208            filtered = filter_block_increasing(&filtered, |p| p.k1);
209        }
210        Some(crate::block_face_functions::FaceAxis::J) => {
211            filtered = filter_block_increasing(&filtered, |p| p.i1);
212            filtered = filter_block_increasing(&filtered, |p| p.k1);
213        }
214        Some(crate::block_face_functions::FaceAxis::K) => {
215            filtered = filter_block_increasing(&filtered, |p| p.i1);
216            filtered = filter_block_increasing(&filtered, |p| p.j1);
217        }
218        None => {}
219    }
220    match face2.const_axis() {
221        Some(crate::block_face_functions::FaceAxis::I) => {
222            filtered = filter_block_increasing(&filtered, |p| p.j2);
223            filtered = filter_block_increasing(&filtered, |p| p.k2);
224        }
225        Some(crate::block_face_functions::FaceAxis::J) => {
226            filtered = filter_block_increasing(&filtered, |p| p.i2);
227            filtered = filter_block_increasing(&filtered, |p| p.k2);
228        }
229        Some(crate::block_face_functions::FaceAxis::K) => {
230            filtered = filter_block_increasing(&filtered, |p| p.i2);
231            filtered = filter_block_increasing(&filtered, |p| p.j2);
232        }
233        None => {}
234    }
235    filtered
236}
237
238/// Build subfaces produced by the intersection region.
239fn create_split_faces(
240    face: &Face,
241    block: &Block,
242    points: &[MatchPoint],
243    use_block1: bool,
244) -> Vec<Face> {
245    if points.is_empty() {
246        return Vec::new();
247    }
248    let (i_lo, i_hi, j_lo, j_hi, k_lo, k_hi) = match_point_bounds(points, use_block1);
249    let degeneracy =
250        usize::from(i_lo == i_hi) + usize::from(j_lo == j_hi) + usize::from(k_lo == k_hi);
251    if degeneracy != 1 {
252        return Vec::new();
253    }
254    let mut split = split_face(face, block, i_lo, j_lo, k_lo, i_hi, j_hi, k_hi);
255    for f in &mut split {
256        if let Some(idx) = face.block_index() {
257            f.set_block_index(idx);
258        }
259        if let Some(id) = face.id() {
260            f.set_id(id);
261        }
262    }
263    split
264}
265
266/// Compute the coincident nodes between two faces on separate blocks.
267///
268/// # Arguments
269/// * `face1` - Candidate face on `block1`.
270/// * `face2` - Candidate face on `block2`.
271/// * `block1` / `block2` - Parent blocks.
272/// * `tol` - Euclidean tolerance for node matching.
273///
274/// # Returns
275/// Tuple containing:
276/// 1. List of [`MatchPoint`]s.
277/// 2. Split faces generated on `block1`.
278/// 3. Split faces generated on `block2`.
279pub fn get_face_intersection(
280    face1: &Face,
281    face2: &Face,
282    block1: &Block,
283    block2: &Block,
284    tol: Float,
285) -> (Vec<MatchPoint>, Vec<Face>, Vec<Face>) {
286    let nodes1 = face_nodes(face1, block1);
287    let nodes2 = face_nodes(face2, block2);
288    let mut matches = Vec::new();
289    for node1 in &nodes1 {
290        if let Some(node2) = find_closest_node(&nodes2, node1.coord, tol) {
291            matches.push(MatchPoint {
292                i1: node1.i,
293                j1: node1.j,
294                k1: node1.k,
295                i2: node2.i,
296                j2: node2.j,
297                k2: node2.k,
298            });
299        }
300    }
301    if matches.len() < 4 || is_edge(&matches) {
302        return (Vec::new(), Vec::new(), Vec::new());
303    }
304    let matches = apply_axis_filters(matches, face1, face2);
305    if matches.len() < 4 {
306        return (Vec::new(), Vec::new(), Vec::new());
307    }
308
309    let split_faces1 = create_split_faces(face1, block1, &matches, true);
310    let split_faces2 = create_split_faces(face2, block2, &matches, false);
311    (matches, split_faces1, split_faces2)
312}
313
314// ---------------------------------------------------------------------------
315// Orientation-aware MatchPoint generation
316// ---------------------------------------------------------------------------
317
318use crate::block_face_functions::FaceAxis;
319
320/// Extract the (u, v) index ranges for a face based on its constant axis.
321fn face_uv_ranges(
322    face: &Face,
323    axis: FaceAxis,
324) -> (
325    std::ops::RangeInclusive<usize>,
326    std::ops::RangeInclusive<usize>,
327) {
328    match axis {
329        FaceAxis::I => (face.jmin()..=face.jmax(), face.kmin()..=face.kmax()),
330        FaceAxis::J => (face.imin()..=face.imax(), face.kmin()..=face.kmax()),
331        FaceAxis::K => (face.imin()..=face.imax(), face.jmin()..=face.jmax()),
332    }
333}
334
335/// Convert parametric (u, v) back to structured (i, j, k) given the constant axis.
336fn uv_to_ijk(u: usize, v: usize, axis: FaceAxis, face: &Face) -> (usize, usize, usize) {
337    match axis {
338        FaceAxis::I => (face.imin(), u, v), // u=j, v=k
339        FaceAxis::J => (u, face.jmin(), v), // u=i, v=k
340        FaceAxis::K => (u, v, face.kmin()), // u=i, v=j
341    }
342}
343
344/// Given a full face match with known orientation, enumerate all corresponding
345/// node pairs by walking both grids in lock-step.
346///
347/// This avoids the O(N*M) closest-node search used for partial matches.
348fn build_match_points_from_orientation(
349    face1: &Face,
350    face2: &Face,
351    orientation: &Orientation,
352) -> Vec<MatchPoint> {
353    let Some(axis1) = face1.const_axis() else {
354        return Vec::new();
355    };
356    let Some(axis2) = face2.const_axis() else {
357        return Vec::new();
358    };
359
360    let (u1_range, v1_range) = face_uv_ranges(face1, axis1);
361    let (u2_range, v2_range) = face_uv_ranges(face2, axis2);
362
363    let u1_vals: Vec<usize> = u1_range.collect();
364    let v1_vals: Vec<usize> = v1_range.collect();
365    let u2_vals: Vec<usize> = u2_range.collect();
366    let v2_vals: Vec<usize> = v2_range.collect();
367
368    let mut points = Vec::with_capacity(u1_vals.len() * v1_vals.len());
369
370    for (u_off, &u1) in u1_vals.iter().enumerate() {
371        for (v_off, &v1) in v1_vals.iter().enumerate() {
372            // Apply orientation mapping to get face2's (u, v) offsets
373            let (u2_off, v2_off) = if orientation.swapped() {
374                (v_off, u_off)
375            } else {
376                (u_off, v_off)
377            };
378
379            let u2_idx = if orientation.u_reversed() {
380                u2_vals.len().saturating_sub(1).saturating_sub(u2_off)
381            } else {
382                u2_off
383            };
384            let v2_idx = if orientation.v_reversed() {
385                v2_vals.len().saturating_sub(1).saturating_sub(v2_off)
386            } else {
387                v2_off
388            };
389
390            if u2_idx >= u2_vals.len() || v2_idx >= v2_vals.len() {
391                continue;
392            }
393
394            let (i1, j1, k1) = uv_to_ijk(u1, v1, axis1, face1);
395            let (i2, j2, k2) = uv_to_ijk(u2_vals[u2_idx], v2_vals[v2_idx], axis2, face2);
396
397            points.push(MatchPoint {
398                i1,
399                j1,
400                k1,
401                i2,
402                j2,
403                k2,
404            });
405        }
406    }
407    points
408}
409
410// ---------------------------------------------------------------------------
411// Phase 1: Fast full-face matching using corner comparison
412// ---------------------------------------------------------------------------
413
414/// Phase 1: Fast full-face matching using canonical grid + permutation matrices.
415///
416/// For each candidate block pair, uses corner comparison as a quick pre-filter,
417/// then verifies the match using `extract_canonical_grid` + `try_all_permutations`.
418/// Face A is the first face, Face B is multiplied by the permutation matrix,
419/// and all points are compared within tolerance.
420///
421/// Returns `(matches, consumed_face_keys)`.
422fn find_full_face_matches(
423    blocks: &[Block],
424    block_outer_faces: &[Vec<Face>],
425    candidate_pairs: &[(usize, usize)],
426    tol: Float,
427) -> (Vec<FaceMatch>, HashSet<FaceKey>) {
428    use crate::block_face_functions::full_face_match;
429    use crate::verification::{
430        determine_plane, extract_canonical_grid, try_all_permutations,
431    };
432
433    let mut face_matches = Vec::new();
434    let mut consumed: HashSet<FaceKey> = HashSet::new();
435
436    for &(i, j) in candidate_pairs {
437        for face_i in &block_outer_faces[i] {
438            if consumed.contains(&face_i.index_key()) {
439                continue;
440            }
441            for face_j in &block_outer_faces[j] {
442                if consumed.contains(&face_j.index_key()) {
443                    continue;
444                }
445                // Quick corner pre-filter
446                if full_face_match(face_i, face_j, tol).is_none() {
447                    continue;
448                }
449
450                // Build FaceRecords for canonical grid extraction
451                let rec_a = FaceRecord::from_face(face_i);
452                let rec_b = FaceRecord::from_face(face_j);
453
454                // Extract canonical grids: Face A and Face B
455                let (pts_a, nu_a, nv_a) = match extract_canonical_grid(&blocks[i], &rec_a) {
456                    Some(g) => g,
457                    None => continue,
458                };
459                let (pts_b, nu_b, nv_b) = match extract_canonical_grid(&blocks[j], &rec_b) {
460                    Some(g) => g,
461                    None => continue,
462                };
463
464                // Face B * permutation matrix, then compare with Face A
465                if let Some(perm_idx) =
466                    try_all_permutations(&pts_a, nu_a, nv_a, &pts_b, nu_b, nv_b, tol)
467                {
468                    let plane = determine_plane(&rec_a, &rec_b);
469                    let orientation = Orientation {
470                        permutation_index: perm_idx,
471                        plane,
472                    };
473
474                    // Build match points from the verified orientation
475                    let points =
476                        build_match_points_from_orientation(face_i, face_j, &orientation);
477
478                    consumed.insert(face_i.index_key());
479                    consumed.insert(face_j.index_key());
480
481                    face_matches.push(FaceMatch {
482                        block1: rec_a,
483                        block2: rec_b,
484                        points,
485                        orientation: Some(orientation),
486                    });
487                    break; // face_i consumed, move on
488                }
489            }
490        }
491    }
492
493    (face_matches, consumed)
494}
495
496// ---------------------------------------------------------------------------
497// Phase 2: Slow partial-face matching with node-by-node comparison
498// ---------------------------------------------------------------------------
499
500/// Recursively match all faces between a pair of blocks.
501///
502/// # Arguments
503/// * `block1` / `block2` - Blocks to compare.
504/// * `block1_outer` / `block2_outer` - Mutable outer-face lists that will be
505///   updated in-place as faces are split.
506/// * `tol` - Node matching tolerance.
507///
508/// # Returns
509/// Collection of match-point arrays, one entry per detected interface.
510pub fn find_matching_blocks(
511    block1: &Block,
512    block2: &Block,
513    block1_outer: &mut Vec<Face>,
514    block2_outer: &mut Vec<Face>,
515    tol: Float,
516) -> Vec<Vec<MatchPoint>> {
517    let mut matches = Vec::new();
518    let mut i = 0;
519    'outer: while i < block1_outer.len() {
520        let mut j = 0;
521        while j < block2_outer.len() {
522            let face1 = block1_outer[i].clone();
523            let face2 = block2_outer[j].clone();
524            let (match_points, split1, split2) =
525                get_face_intersection(&face1, &face2, block1, block2, tol);
526            if !match_points.is_empty() {
527                matches.push(match_points.clone());
528
529                block1_outer.remove(i);
530                block2_outer.remove(j);
531                block1_outer.extend(split1);
532                block2_outer.extend(split2);
533                i = 0;
534                continue 'outer;
535            } else {
536                j += 1;
537            }
538        }
539        i += 1;
540    }
541    matches
542}
543
544/// Return `(i, j)` block index pairs whose axis-aligned bounding boxes overlap
545/// or nearly touch within `tol`.
546///
547/// This replaces the former centroid-distance approach which only considered
548/// the 6 nearest blocks and could miss neighbours for L-shaped or elongated
549/// geometries.  AABB overlap is both more robust and more correct.
550///
551/// # Arguments
552/// * `blocks` - All blocks in the assembly.
553/// * `tol` - AABB expansion tolerance.  Blocks whose bounding boxes are within
554///   this distance of touching are still considered candidates.
555///
556/// # Returns
557/// Vector of `(i, j)` pairs with `i < j`.
558fn candidate_neighbor_pairs(blocks: &[Block], tol: Float) -> Vec<(usize, usize)> {
559    use rayon::prelude::*;
560
561    let n = blocks.len();
562    // Precompute AABBs: [xmin, xmax, ymin, ymax, zmin, zmax]
563    let aabbs: Vec<[Float; 6]> = blocks
564        .par_iter()
565        .map(|b| {
566            let mut xmin = Float::INFINITY;
567            let mut xmax = Float::NEG_INFINITY;
568            let mut ymin = Float::INFINITY;
569            let mut ymax = Float::NEG_INFINITY;
570            let mut zmin = Float::INFINITY;
571            let mut zmax = Float::NEG_INFINITY;
572            for &x in &b.x {
573                xmin = xmin.min(x);
574                xmax = xmax.max(x);
575            }
576            for &y in &b.y {
577                ymin = ymin.min(y);
578                ymax = ymax.max(y);
579            }
580            for &z in &b.z {
581                zmin = zmin.min(z);
582                zmax = zmax.max(z);
583            }
584            [xmin, xmax, ymin, ymax, zmin, zmax]
585        })
586        .collect();
587
588    let pairs: Vec<(usize, usize)> = (0..n)
589        .into_par_iter()
590        .flat_map(|i| {
591            let aabbs = &aabbs;
592            ((i + 1)..n)
593                .filter_map(move |j| {
594                    let a = &aabbs[i];
595                    let b = &aabbs[j];
596                    if a[1] + tol >= b[0]
597                        && b[1] + tol >= a[0]
598                        && a[3] + tol >= b[2]
599                        && b[3] + tol >= a[2]
600                        && a[5] + tol >= b[4]
601                        && b[5] + tol >= a[4]
602                    {
603                        Some((i, j))
604                    } else {
605                        None
606                    }
607                })
608                .collect::<Vec<_>>()
609        })
610        .collect();
611    pairs
612}
613
614/// Connectivity computation performed on GCD-reduced blocks.
615///
616/// This is the main entry point for connectivity detection. It down-samples
617/// all blocks by the minimum GCD of their dimensions, runs the three-phase
618/// connectivity algorithm (see module docs), then scales indices back to
619/// the original resolution.
620///
621/// # Arguments
622/// * `blocks` - Original block list. Each block is down-sampled by the
623///   smallest index GCD across the set.
624///
625/// # Returns
626/// Tuple `(matches, outer_faces)` where `matches` enumerates face interfaces
627/// and `outer_faces` records the remaining external surfaces at the original
628/// resolution.
629pub fn connectivity_fast(blocks: &[Block]) -> (Vec<FaceMatch>, Vec<FaceRecord>) {
630    let gcd_to_use = crate::utils::compute_min_gcd(blocks);
631    let reduced_blocks = crate::block_face_functions::reduce_blocks(blocks, gcd_to_use);
632    let (mut matches, mut outer_faces) = connectivity(&reduced_blocks);
633    // Scale back to original size
634    for face in &mut matches {
635        face.block1.scale_indices(gcd_to_use);
636        face.block2.scale_indices(gcd_to_use);
637    }
638    for face in &mut outer_faces {
639        face.scale_indices(gcd_to_use);
640    }
641    (matches, outer_faces)
642}
643
644/// Determine face-to-face connectivity and exterior faces for all blocks.
645///
646/// # Arguments
647/// * `blocks` - Full-resolution blocks to analyse.
648///
649/// # Returns
650/// Tuple `(matches, outer_faces)` representing matched interfaces and the
651/// formatted list of outer faces.
652pub fn connectivity(blocks: &[Block]) -> (Vec<FaceMatch>, Vec<FaceRecord>) {
653    use rayon::prelude::*;
654
655    // Parallelize outer face extraction per block.
656    // Extract outer faces for each block. Degenerate face pairs (where
657    // opposite sides coincide) are NOT added to the inter-block pool —
658    // they are handled as self-matches later in this function (lines 856+).
659    // This matches the Python behavior where get_outer_faces returns
660    // (non_matching, matching) and only non_matching enters the pool.
661    let mut block_outer_faces: Vec<Vec<Face>> = blocks
662        .par_iter()
663        .enumerate()
664        .map(|(idx, block)| {
665            let (faces, _degenerate_pairs) = get_outer_faces(block);
666            faces
667                .into_iter()
668                .map(|mut f| {
669                    f.set_block_index(idx);
670                    f
671                })
672                .collect()
673        })
674        .collect();
675
676    let combos = candidate_neighbor_pairs(blocks, DEFAULT_TOL);
677
678    // ===== PHASE 1: Full face matching (fast, corner-based + interior verification) =====
679    let (mut matches, consumed_keys) =
680        find_full_face_matches(blocks, &block_outer_faces, &combos, DEFAULT_TOL);
681
682    // Remove fully-matched faces from the outer face pools
683    for faces in &mut block_outer_faces {
684        faces.retain(|f| !consumed_keys.contains(&f.index_key()));
685    }
686
687    let mut matches_to_remove: HashSet<FaceKey> = consumed_keys;
688
689    // ===== PHASE 2: Partial face matching (slow, node-by-node) =====
690    // Iterate until convergence: after splitting faces for one block pair,
691    // the remnants may match faces from other blocks that were previously
692    // consumed. Repeat until no new matches are found.
693    let mut phase2_round = 0;
694    let mut phase2_changed = true;
695    while phase2_changed {
696        phase2_changed = false;
697        phase2_round += 1;
698
699        let pb = ProgressBar::new(combos.len() as u64);
700        pb.set_style(
701            ProgressStyle::with_template(
702                "{msg} [{bar:40.cyan/blue}] {pos}/{len} pairs ({eta} remaining)",
703            )
704            .unwrap()
705            .progress_chars("=>-"),
706        );
707        pb.set_message(format!(
708            "Connectivity (partial matching, round {})",
709            phase2_round
710        ));
711
712        for &(i, j) in &combos {
713            pb.inc(1);
714            // candidate_neighbor_pairs guarantees i < j
715            let (left, right) = block_outer_faces.split_at_mut(j);
716            let (left, right) = (&mut left[i], &mut right[0]);
717
718            // Skip if either block has no remaining unmatched faces
719            if left.is_empty() || right.is_empty() {
720                continue;
721            }
722
723            let mut match_points =
724                find_matching_blocks(&blocks[i], &blocks[j], left, right, DEFAULT_TOL);
725            for points in match_points.drain(..) {
726                phase2_changed = true;
727                let (i1lo, i1hi, j1lo, j1hi, k1lo, k1hi) = match_point_bounds(&points, true);
728                let mut face1 = create_face_from_diagonals(
729                    &blocks[i], i1lo, j1lo, k1lo, i1hi, j1hi, k1hi,
730                );
731                face1.set_block_index(i);
732                let (i2lo, i2hi, j2lo, j2hi, k2lo, k2hi) = match_point_bounds(&points, false);
733                let mut face2 = create_face_from_diagonals(
734                    &blocks[j], i2lo, j2lo, k2lo, i2hi, j2hi, k2hi,
735                );
736                face2.set_block_index(j);
737                matches_to_remove.insert(face1.index_key());
738                matches_to_remove.insert(face2.index_key());
739
740                let corner1 = FaceRecord::from_match_points(i, &points, true).unwrap();
741                let corner2 = FaceRecord::from_match_points(j, &points, false).unwrap();
742                matches.push(FaceMatch {
743                    block1: corner1,
744                    block2: corner2,
745                    points,
746                    orientation: None,
747                });
748            }
749        }
750        pb.finish_with_message(format!(
751            "Connectivity round {} done (changed={})",
752            phase2_round, phase2_changed
753        ));
754    }
755
756    let mut outer_faces = Vec::new();
757    for faces in &block_outer_faces {
758        for face in faces {
759            outer_faces.push(face.clone());
760        }
761    }
762    // Free large temporaries now that we've extracted what we need
763    drop(block_outer_faces);
764
765    let mut seen = HashSet::new();
766    outer_faces.retain(|face| seen.insert(face.index_key()));
767
768    outer_faces.retain(|face| !matches_to_remove.contains(&face.index_key()));
769    drop(matches_to_remove);
770
771    let mut outer_faces_to_remove = HashSet::new();
772    let mut by_block: HashMap<usize, Vec<&Face>> = HashMap::new();
773    for face in &outer_faces {
774        if let Some(idx) = face.block_index() {
775            by_block.entry(idx).or_default().push(face);
776        }
777    }
778    for faces in by_block.values() {
779        for (a_idx, face_a) in faces.iter().enumerate() {
780            let dims_a = [
781                face_a.imin(),
782                face_a.jmin(),
783                face_a.kmin(),
784                face_a.imax(),
785                face_a.jmax(),
786                face_a.kmax(),
787            ];
788            for (b_idx, face_b) in faces.iter().enumerate() {
789                if a_idx == b_idx {
790                    continue;
791                }
792                let dims_b = [
793                    face_b.imin(),
794                    face_b.jmin(),
795                    face_b.kmin(),
796                    face_b.imax(),
797                    face_b.jmax(),
798                    face_b.kmax(),
799                ];
800                let equal_components = dims_a
801                    .iter()
802                    .zip(dims_b.iter())
803                    .filter(|(a, b)| a == b)
804                    .count();
805                if equal_components == 5 {
806                    let remove_key = if face_b.diagonal_length() > face_a.diagonal_length() {
807                        face_b.index_key()
808                    } else {
809                        face_a.index_key()
810                    };
811                    outer_faces_to_remove.insert(remove_key);
812                }
813            }
814        }
815    }
816
817    outer_faces.retain(|face| !outer_faces_to_remove.contains(&face.index_key()));
818
819    let mut self_match_keys: HashSet<FaceKey> = HashSet::new();
820    for (idx, block) in blocks.iter().enumerate() {
821        let (_, self_matches) = get_outer_faces(block);
822        for (face_a, face_b) in self_matches {
823            let mut corner1 = FaceRecord {
824                block_index: idx,
825                il: face_a.imin(),
826                jl: face_a.jmin(),
827                kl: face_a.kmin(),
828                ih: face_a.imax(),
829                jh: face_a.jmax(),
830                kh: face_a.kmax(),
831                id: face_a.id(),
832                u_physical: None,
833                v_physical: None,
834            };
835            let corner2 = FaceRecord {
836                block_index: idx,
837                il: face_b.imin(),
838                jl: face_b.jmin(),
839                kl: face_b.kmin(),
840                ih: face_b.imax(),
841                jh: face_b.jmax(),
842                kh: face_b.kmax(),
843                id: face_b.id(),
844                u_physical: None,
845                v_physical: None,
846            };
847            // Track self-matched face keys so they can be removed from outer faces
848            let mut fa = face_a.clone();
849            fa.set_block_index(idx);
850            let mut fb = face_b.clone();
851            fb.set_block_index(idx);
852            self_match_keys.insert(fa.index_key());
853            self_match_keys.insert(fb.index_key());
854
855            corner1.id = face_a.id();
856            matches.push(FaceMatch {
857                block1: corner1,
858                block2: corner2,
859                points: Vec::new(),
860                orientation: None,
861            });
862        }
863    }
864
865    // Remove self-matched faces from outer faces
866    outer_faces.retain(|face| !self_match_keys.contains(&face.index_key()));
867
868    // ===== PHASE 3: Fresh-face validation for remaining outer faces =====
869    // Some outer faces remain unmatched because the matching block's face pool
870    // was consumed by an earlier combo in Phases 1–2.  Re-check each remaining
871    // outer face against *fresh* (un-consumed) outer faces of overlapping blocks.
872    {
873        let mut neighbors: Vec<Vec<usize>> = vec![Vec::new(); blocks.len()];
874        for &(i, j) in &combos {
875            neighbors[i].push(j);
876            neighbors[j].push(i);
877        }
878
879        // Precompute fresh outer faces and their AABBs for every block.
880        // We compute the AABB from ALL face nodes (not just 2 diagonal corners)
881        // because large curved faces can have interior extents far beyond their
882        // corner coordinates.
883        let fresh_all: Vec<Vec<Face>> = blocks
884            .iter()
885            .map(|block| {
886                let (faces, _) = get_outer_faces(block);
887                faces
888            })
889            .collect();
890
891        // Precompute [xmin, xmax, ymin, ymax, zmin, zmax] for each fresh face.
892        let fresh_aabbs: Vec<Vec<[Float; 6]>> = blocks
893            .iter()
894            .zip(fresh_all.iter())
895            .map(|(block, faces)| {
896                faces
897                    .iter()
898                    .map(|f| {
899                        let nodes = face_nodes(f, block);
900                        let mut aabb = [
901                            Float::INFINITY,
902                            Float::NEG_INFINITY,
903                            Float::INFINITY,
904                            Float::NEG_INFINITY,
905                            Float::INFINITY,
906                            Float::NEG_INFINITY,
907                        ];
908                        for n in &nodes {
909                            aabb[0] = aabb[0].min(n.coord[0]);
910                            aabb[1] = aabb[1].max(n.coord[0]);
911                            aabb[2] = aabb[2].min(n.coord[1]);
912                            aabb[3] = aabb[3].max(n.coord[1]);
913                            aabb[4] = aabb[4].min(n.coord[2]);
914                            aabb[5] = aabb[5].max(n.coord[2]);
915                        }
916                        aabb
917                    })
918                    .collect()
919            })
920            .collect();
921
922        let pb = ProgressBar::new(outer_faces.len() as u64);
923        pb.set_style(
924            ProgressStyle::with_template(
925                "{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta} remaining)",
926            )
927            .unwrap()
928            .progress_chars("=>-"),
929        );
930        pb.set_message("Connectivity Phase 3 (fresh-face validation)");
931
932        let mut phase3_keys: HashSet<FaceKey> = HashSet::new();
933
934        for face in outer_faces.iter() {
935            pb.inc(1);
936            if phase3_keys.contains(&face.index_key()) {
937                continue;
938            }
939            let bi = match face.block_index() {
940                Some(v) => v,
941                None => continue,
942            };
943
944            // Compute proper AABB of this face from all its nodes
945            let face_nodes_list = face_nodes(face, &blocks[bi]);
946            let mut fxn = Float::INFINITY;
947            let mut fxx = Float::NEG_INFINITY;
948            let mut fyn = Float::INFINITY;
949            let mut fyx = Float::NEG_INFINITY;
950            let mut fzn = Float::INFINITY;
951            let mut fzx = Float::NEG_INFINITY;
952            for n in &face_nodes_list {
953                fxn = fxn.min(n.coord[0]);
954                fxx = fxx.max(n.coord[0]);
955                fyn = fyn.min(n.coord[1]);
956                fyx = fyx.max(n.coord[1]);
957                fzn = fzn.min(n.coord[2]);
958                fzx = fzx.max(n.coord[2]);
959            }
960
961            for &bj in &neighbors[bi] {
962                for (fi, ff) in fresh_all[bj].iter().enumerate() {
963                    // AABB pre-check using precomputed all-node AABBs
964                    let gaabb = &fresh_aabbs[bj][fi];
965                    let tol_pre = 0.01;
966                    if fxx + tol_pre < gaabb[0]
967                        || gaabb[1] + tol_pre < fxn
968                        || fyx + tol_pre < gaabb[2]
969                        || gaabb[3] + tol_pre < fyn
970                        || fzx + tol_pre < gaabb[4]
971                        || gaabb[5] + tol_pre < fzn
972                    {
973                        continue;
974                    }
975
976                    let (pts, _, _) =
977                        get_face_intersection(face, ff, &blocks[bi], &blocks[bj], DEFAULT_TOL);
978                    if pts.is_empty() {
979                        continue;
980                    }
981                    if let (Some(c1), Some(c2)) = (
982                        FaceRecord::from_match_points(bi, &pts, true),
983                        FaceRecord::from_match_points(bj, &pts, false),
984                    ) {
985                        matches.push(FaceMatch {
986                            block1: c1,
987                            block2: c2,
988                            points: pts,
989                            orientation: None,
990                        });
991                    }
992                    phase3_keys.insert(face.index_key());
993                    // Don't break — continue checking other neighbors' faces
994                    // for split-face matches where multiple blocks cover parts
995                    // of the same remaining face.
996                }
997            }
998        }
999
1000        let n3 = phase3_keys.len();
1001        pb.finish_with_message(format!("Phase 3 done ({n3} new matches)"));
1002
1003        outer_faces.retain(|f| !phase3_keys.contains(&f.index_key()));
1004    }
1005
1006    let mut formatted = Vec::new();
1007    let mut id_counter = 1;
1008    for face in outer_faces {
1009        formatted.push(FaceRecord {
1010            block_index: face.block_index().unwrap_or(usize::MAX),
1011            il: face.imin(),
1012            jl: face.jmin(),
1013            kl: face.kmin(),
1014            ih: face.imax(),
1015            jh: face.jmax(),
1016            kh: face.kmax(),
1017            id: Some(id_counter),
1018            u_physical: None,
1019            v_physical: None,
1020        });
1021        id_counter += 1;
1022    }
1023
1024    (matches, formatted)
1025}
1026
1027/// Find the correct orientation for each face match using permutation matrices.
1028///
1029/// Extracts canonical grids for both faces and tries all 8 permutation
1030/// matrices on face B. Stores the winning `permutation_index` and plane
1031/// type in [`FaceMatch::orientation`].
1032///
1033/// # Arguments
1034/// * `blocks` - Block array providing geometry.
1035/// * `face_matches` - Verified face matches (from `verify_connectivity`).
1036/// * `tol` - Euclidean distance tolerance.
1037///
1038/// # Returns
1039/// `(aligned, rejected)` — aligned matches with corrected block2 diagonals,
1040/// and rejected matches where no orientation produces point-by-point equality.
1041pub fn align_face_orientations(
1042    blocks: &[Block],
1043    face_matches: &[FaceMatch],
1044    tol: Float,
1045) -> (Vec<FaceMatch>, Vec<FaceMatch>) {
1046    let mut aligned = Vec::new();
1047    let mut rejected = Vec::new();
1048
1049    let pb = ProgressBar::new(face_matches.len() as u64);
1050    pb.set_style(
1051        ProgressStyle::with_template(
1052            "{msg} [{bar:40.cyan/blue}] {pos}/{len} matches ({eta} remaining)",
1053        )
1054        .unwrap()
1055        .progress_chars("=>-"),
1056    );
1057    pb.set_message("Align orientations");
1058
1059    for fm in face_matches {
1060        pb.inc(1);
1061        let b1 = &fm.block1;
1062        let b2 = &fm.block2;
1063
1064        if b1.block_index >= blocks.len() || b2.block_index >= blocks.len() {
1065            rejected.push(fm.clone());
1066            continue;
1067        }
1068
1069        let block1 = &blocks[b1.block_index];
1070        let block2 = &blocks[b2.block_index];
1071
1072        // Extract canonical 2D grids for both faces
1073        let grid_a = match extract_canonical_grid(block1, b1) {
1074            Some(g) => g,
1075            None => { rejected.push(fm.clone()); continue; }
1076        };
1077        let grid_b = match extract_canonical_grid(block2, b2) {
1078            Some(g) => g,
1079            None => { rejected.push(fm.clone()); continue; }
1080        };
1081
1082        let (pts_a, nu_a, nv_a) = grid_a;
1083        let (pts_b, nu_b, nv_b) = grid_b;
1084
1085        // Try all 8 permutation matrices to find the matching orientation
1086        if let Some(perm_idx) = try_all_permutations(&pts_a, nu_a, nv_a, &pts_b, nu_b, nv_b, tol) {
1087            let mut fm_out = fm.clone();
1088            let plane = determine_plane(b1, b2);
1089            fm_out.orientation = Some(Orientation {
1090                permutation_index: perm_idx,
1091                plane,
1092            });
1093            aligned.push(fm_out);
1094        } else {
1095            eprintln!(
1096                "  align: REJECTED block {}↔{} — no permutation matches",
1097                b1.block_index, b2.block_index
1098            );
1099            rejected.push(fm.clone());
1100        }
1101    }
1102
1103    pb.finish_with_message("Align orientations done");
1104    (aligned, rejected)
1105}
1106
1107/// Derive lb/ub for both faces from the first/last MatchPoint.
1108///
1109/// The traversal order of MatchPoints encodes the orientation relationship
1110/// between the two faces, which is lost if we use min/max or spatial proximity.
1111fn derive_diagonal_from_match_points(
1112    fm: &FaceMatch,
1113    gcd: usize,
1114) -> Option<(FaceRecord, FaceRecord)> {
1115    let points = &fm.points;
1116    if points.is_empty() {
1117        return None;
1118    }
1119
1120    let first = &points[0];
1121    let last = &points[points.len() - 1];
1122
1123    let b1 = FaceRecord {
1124        block_index: fm.block1.block_index,
1125        il: first.i1 * gcd,
1126        jl: first.j1 * gcd,
1127        kl: first.k1 * gcd,
1128        ih: last.i1 * gcd,
1129        jh: last.j1 * gcd,
1130        kh: last.k1 * gcd,
1131        id: fm.block1.id,
1132        u_physical: None,
1133        v_physical: None,
1134    };
1135    let b2 = FaceRecord {
1136        block_index: fm.block2.block_index,
1137        il: first.i2 * gcd,
1138        jl: first.j2 * gcd,
1139        kl: first.k2 * gcd,
1140        ih: last.i2 * gcd,
1141        jh: last.j2 * gcd,
1142        kh: last.k2 * gcd,
1143        id: fm.block2.id,
1144        u_physical: None,
1145        v_physical: None,
1146    };
1147
1148    Some((b1, b2))
1149}
1150
1151/// Validate and standardize face-match records.
1152///
1153/// For matches with MatchPoint data, derives diagonal corners from the
1154/// first/last traversal points. For self-matches (no MatchPoint data),
1155/// uses spatial proximity as a fallback.
1156///
1157/// # Arguments
1158/// * `blocks` - Block array providing geometry (full resolution).
1159/// * `face_matches` - Matches to validate.
1160///
1161/// # Returns
1162/// Validated face matches with corrected diagonal indices.
1163pub fn face_matches_to_dict(blocks: &[Block], face_matches: &[FaceMatch]) -> Vec<FaceMatch> {
1164    // GCD factor: MatchPoint indices are on the reduced grid while FaceRecord
1165    // indices have already been scaled up by this factor.
1166    let gcd = crate::utils::compute_min_gcd(blocks);
1167
1168    let mut matched_count = 0usize;
1169    let mut empty_count = 0usize;
1170    let mut spatial_count = 0usize;
1171
1172    let result: Vec<FaceMatch> = face_matches
1173        .iter()
1174        .filter_map(|fm| {
1175            let b1 = &fm.block1;
1176            let b2 = &fm.block2;
1177
1178            let block1 = blocks.get(b1.block_index)?;
1179            let block2 = blocks.get(b2.block_index)?;
1180
1181            let mut result = fm.clone();
1182
1183            if !fm.points.is_empty() {
1184                // Has MatchPoint data — use spatial proximity derivation
1185                if let Some((b1_new, b2_new)) =
1186                    derive_diagonal_from_match_points(fm, gcd)
1187                {
1188                    result.block1 = b1_new;
1189                    result.block2 = b2_new;
1190                    matched_count += 1;
1191                } else {
1192                    empty_count += 1;
1193                }
1194            } else {
1195                // No MatchPoints (self-matches): spatial search
1196                // over block2's bounding-box corners.
1197                let (x1_l, y1_l, z1_l) = block1.xyz(b1.il, b1.jl, b1.kl);
1198
1199                let i_vals = [b2.i_lo(), b2.i_hi()];
1200                let j_vals = [b2.j_lo(), b2.j_hi()];
1201                let k_vals = [b2.k_lo(), b2.k_hi()];
1202
1203                let mut best_lower = (Float::MAX, b2.il, b2.jl, b2.kl);
1204                for &i in &i_vals {
1205                    for &j in &j_vals {
1206                        for &k in &k_vals {
1207                            let (x2, y2, z2) = block2.xyz(i, j, k);
1208                            let d = ((x2 - x1_l).powi(2)
1209                                + (y2 - y1_l).powi(2)
1210                                + (z2 - z1_l).powi(2))
1211                            .sqrt();
1212                            if d < best_lower.0 {
1213                                best_lower = (d, i, j, k);
1214                            }
1215                        }
1216                    }
1217                }
1218                result.block2.il = best_lower.1;
1219                result.block2.jl = best_lower.2;
1220                result.block2.kl = best_lower.3;
1221
1222                let (x1_u, y1_u, z1_u) = block1.xyz(b1.ih, b1.jh, b1.kh);
1223
1224                let mut best_upper = (Float::MAX, b2.ih, b2.jh, b2.kh);
1225                for &i in &i_vals {
1226                    for &j in &j_vals {
1227                        for &k in &k_vals {
1228                            let (x2, y2, z2) = block2.xyz(i, j, k);
1229                            let d = ((x2 - x1_u).powi(2)
1230                                + (y2 - y1_u).powi(2)
1231                                + (z2 - z1_u).powi(2))
1232                            .sqrt();
1233                            if d < best_upper.0 {
1234                                best_upper = (d, i, j, k);
1235                            }
1236                        }
1237                    }
1238                }
1239                result.block2.ih = best_upper.1;
1240                result.block2.jh = best_upper.2;
1241                result.block2.kh = best_upper.3;
1242
1243                spatial_count += 1;
1244            }
1245
1246            Some(result)
1247        })
1248        .collect();
1249
1250    eprintln!(
1251        "  face_matches_to_dict: {} matched, {} empty, {} spatial",
1252        matched_count, empty_count, spatial_count
1253    );
1254    result
1255}