Skip to main content

plot3d/
block_face_functions.rs

1use std::collections::HashSet;
2
3use crate::{
4    block::Block,
5    face_record::{FaceKey, FaceMatch, FaceRecord},
6    geometry::{
7        clip_sutherland_hodgman, distance, dominant_projection_axis, poly_area_2d,
8        project_drop_axis, quad_normal_from_verts, quantize_point, to_array, vertex_aabb,
9    },
10    utils::{cross3, dot3, sub3, vec_norm3},
11    Float,
12};
13
14const DEFAULT_TOL: Float = 1e-8;
15
16/// Vertex-matching tolerance used by [`Face::match_indices`].
17const VERTEX_MATCH_TOL: Float = 1e-6;
18
19/// Enumeration describing which index remains constant over a structured face.
20#[derive(Copy, Clone, Debug, Eq, PartialEq)]
21pub enum FaceAxis {
22    /// I-direction is constant.
23    I,
24    /// J-direction is constant.
25    J,
26    /// K-direction is constant.
27    K,
28}
29
30/// Quadrilateral face definition that mimics the Python implementation.
31#[derive(Clone, Debug)]
32pub struct Face {
33    vertices: Vec<[Float; 3]>,
34    indices: Vec<[usize; 3]>,
35    centroid: [Float; 3],
36    block_index: Option<usize>,
37    id: Option<usize>,
38}
39
40/// Find the linear index of the vertex whose structured indices match `(i, j, k)`.
41///
42/// Panics if the requested indices are not found in the face.
43fn corner_index(face: &Face, i: usize, j: usize, k: usize) -> usize {
44    face.indices
45        .iter()
46        .enumerate()
47        .find_map(|(idx, ijk)| {
48            if ijk[0] == i && ijk[1] == j && ijk[2] == k {
49                Some(idx)
50            } else {
51                None
52            }
53        })
54        .unwrap_or_else(|| {
55            panic!(
56                "corner_index: vertex ({}, {}, {}) not found in face with {} vertices",
57                i,
58                j,
59                k,
60                face.indices.len()
61            )
62        })
63}
64
65impl Default for Face {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl Face {
72    /// Create an empty face.
73    pub fn new() -> Self {
74        Self {
75            vertices: Vec::with_capacity(4),
76            indices: Vec::with_capacity(4),
77            centroid: [0.0; 3],
78            block_index: None,
79            id: None,
80        }
81    }
82
83    /// Add a vertex and update the centroid.
84    ///
85    /// * `x`, `y`, `z` - Cartesian coordinates.
86    /// * `i`, `j`, `k` - Structured-grid indices.
87    pub fn add_vertex(&mut self, x: Float, y: Float, z: Float, i: usize, j: usize, k: usize) {
88        self.vertices.push([x, y, z]);
89        self.indices.push([i, j, k]);
90        let n = self.vertices.len() as Float;
91        let mut cx = 0.0;
92        let mut cy = 0.0;
93        let mut cz = 0.0;
94        for v in &self.vertices {
95            cx += v[0];
96            cy += v[1];
97            cz += v[2];
98        }
99        self.centroid = [cx / n, cy / n, cz / n];
100    }
101
102    /// Set the owning block index.
103    pub fn set_block_index(&mut self, idx: usize) {
104        self.block_index = Some(idx);
105    }
106
107    /// Set the application-defined identifier.
108    pub fn set_id(&mut self, id: usize) {
109        self.id = Some(id);
110    }
111
112    /// Identifier, if one has been assigned.
113    pub fn id(&self) -> Option<usize> {
114        self.id
115    }
116
117    /// Retrieve the centroid.
118    pub fn centroid(&self) -> [Float; 3] {
119        self.centroid
120    }
121
122    /// Owning block index, if present.
123    pub fn block_index(&self) -> Option<usize> {
124        self.block_index
125    }
126
127    /// Iterate over stored vertex indices `(i, j, k)`.
128    pub fn indices(&self) -> &[[usize; 3]] {
129        &self.indices
130    }
131
132    /// All I indices used by this face.
133    pub fn i_values(&self) -> impl Iterator<Item = usize> + '_ {
134        self.indices.iter().map(|ijk| ijk[0])
135    }
136
137    /// All J indices used by this face.
138    pub fn j_values(&self) -> impl Iterator<Item = usize> + '_ {
139        self.indices.iter().map(|ijk| ijk[1])
140    }
141
142    /// All K indices used by this face.
143    pub fn k_values(&self) -> impl Iterator<Item = usize> + '_ {
144        self.indices.iter().map(|ijk| ijk[2])
145    }
146
147    fn min_max(dim: usize, indices: &[[usize; 3]]) -> (usize, usize) {
148        let mut min_v = usize::MAX;
149        let mut max_v = 0usize;
150        for idx in indices {
151            min_v = min_v.min(idx[dim]);
152            max_v = max_v.max(idx[dim]);
153        }
154        (min_v, max_v)
155    }
156
157    /// Minimum I index among the vertices.
158    pub fn imin(&self) -> usize {
159        Self::min_max(0, &self.indices).0
160    }
161    /// Maximum I index among the vertices.
162    pub fn imax(&self) -> usize {
163        Self::min_max(0, &self.indices).1
164    }
165    /// Minimum J index among the vertices.
166    pub fn jmin(&self) -> usize {
167        Self::min_max(1, &self.indices).0
168    }
169    /// Maximum J index among the vertices.
170    pub fn jmax(&self) -> usize {
171        Self::min_max(1, &self.indices).1
172    }
173    /// Minimum K index among the vertices.
174    pub fn kmin(&self) -> usize {
175        Self::min_max(2, &self.indices).0
176    }
177    /// Maximum K index among the vertices.
178    pub fn kmax(&self) -> usize {
179        Self::min_max(2, &self.indices).1
180    }
181
182    /// Determine which index is constant, if the face is structured.
183    pub fn const_axis(&self) -> Option<FaceAxis> {
184        let i_same = self.imin() == self.imax();
185        let j_same = self.jmin() == self.jmax();
186        let k_same = self.kmin() == self.kmax();
187        match (i_same, j_same, k_same) {
188            (true, false, false) => Some(FaceAxis::I),
189            (false, true, false) => Some(FaceAxis::J),
190            (false, false, true) => Some(FaceAxis::K),
191            _ => None,
192        }
193    }
194
195    /// Integer constant-type matching the Python convention:
196    /// `0` = I-constant, `1` = J-constant, `2` = K-constant, `-1` = none.
197    pub fn const_type(&self) -> i8 {
198        match self.const_axis() {
199            Some(FaceAxis::I) => 0,
200            Some(FaceAxis::J) => 1,
201            Some(FaceAxis::K) => 2,
202            None => -1,
203        }
204    }
205
206    /// True when the face collapses to an edge.
207    pub fn is_edge(&self) -> bool {
208        let eq = [
209            self.imin() == self.imax(),
210            self.jmin() == self.jmax(),
211            self.kmin() == self.kmax(),
212        ];
213        eq.iter().filter(|&&b| b).count() > 1
214    }
215
216    /// Compare index ranges with another face.
217    pub fn index_equals(&self, other: &Face) -> bool {
218        self.imin() == other.imin()
219            && self.imax() == other.imax()
220            && self.jmin() == other.jmin()
221            && self.jmax() == other.jmax()
222            && self.kmin() == other.kmin()
223            && self.kmax() == other.kmax()
224    }
225
226    /// Read-only access to the stored vertex coordinates.
227    pub fn vertices(&self) -> &[[Float; 3]] {
228        &self.vertices
229    }
230
231    /// Return the spatial coordinates of the two diagonal corners.
232    ///
233    /// The "lower" corner is the vertex at `(IMIN, JMIN, KMIN)` and the
234    /// "upper" corner is at `(IMAX, JMAX, KMAX)`.
235    ///
236    /// Returns `None` when the face has no vertices.
237    pub fn get_corners(&self) -> Option<([Float; 3], [Float; 3])> {
238        if self.vertices.is_empty() {
239            return None;
240        }
241        let min_idx = corner_index(self, self.imin(), self.jmin(), self.kmin());
242        let max_idx = corner_index(self, self.imax(), self.jmax(), self.kmax());
243        Some((self.vertices[min_idx], self.vertices[max_idx]))
244    }
245
246    /// Return all four corner vertices in canonical parametric order.
247    ///
248    /// The ordering matches `create_face_from_diagonals()`:
249    ///   - I-constant: `[(i, jmin, kmin), (i, jmin, kmax), (i, jmax, kmin), (i, jmax, kmax)]`
250    ///   - J-constant: `[(imin, j, kmin), (imin, j, kmax), (imax, j, kmin), (imax, j, kmax)]`
251    ///   - K-constant: `[(imin, jmin, k), (imin, jmax, k), (imax, jmin, k), (imax, jmax, k)]`
252    ///
253    /// Returns `None` if the face has fewer than 4 vertices or no constant axis.
254    pub fn get_all_corners(&self) -> Option<[[Float; 3]; 4]> {
255        if self.vertices.len() < 4 {
256            return None;
257        }
258        let axis = self.const_axis()?;
259        let (imin, imax) = (self.imin(), self.imax());
260        let (jmin, jmax) = (self.jmin(), self.jmax());
261        let (kmin, kmax) = (self.kmin(), self.kmax());
262
263        let find = |ti: usize, tj: usize, tk: usize| -> Option<[Float; 3]> {
264            self.indices.iter().enumerate().find_map(|(idx, ijk)| {
265                if ijk[0] == ti && ijk[1] == tj && ijk[2] == tk {
266                    Some(self.vertices[idx])
267                } else {
268                    None
269                }
270            })
271        };
272
273        match axis {
274            FaceAxis::I => {
275                let ic = imin; // I is constant
276                Some([
277                    find(ic, jmin, kmin)?,
278                    find(ic, jmin, kmax)?,
279                    find(ic, jmax, kmin)?,
280                    find(ic, jmax, kmax)?,
281                ])
282            }
283            FaceAxis::J => {
284                let jc = jmin; // J is constant
285                Some([
286                    find(imin, jc, kmin)?,
287                    find(imin, jc, kmax)?,
288                    find(imax, jc, kmin)?,
289                    find(imax, jc, kmax)?,
290                ])
291            }
292            FaceAxis::K => {
293                let kc = kmin; // K is constant
294                Some([
295                    find(imin, jmin, kc)?,
296                    find(imin, jmax, kc)?,
297                    find(imax, jmin, kc)?,
298                    find(imax, jmax, kc)?,
299                ])
300            }
301        }
302    }
303
304    /// Length of the face diagonal between the extreme corner nodes.
305    pub fn diagonal_length(&self) -> Float {
306        let min_idx = corner_index(self, self.imin(), self.jmin(), self.kmin());
307        let max_idx = corner_index(self, self.imax(), self.jmax(), self.kmax());
308        let p0 = self.vertices[min_idx];
309        let p1 = self.vertices[max_idx];
310        distance(p0, p1)
311    }
312
313    /// Median edge spacing of the face grid using the block's coordinates.
314    ///
315    /// Walks adjacent grid nodes along both parametric directions and returns
316    /// the median of all edge lengths. Falls back to `1.0` for degenerate faces.
317    pub fn median_edge_spacing(&self, block: &Block) -> Float {
318        let axis = match self.const_axis() {
319            Some(a) => a,
320            None => return 1.0,
321        };
322        let mut spacings = Vec::new();
323        match axis {
324            FaceAxis::I => {
325                let ic = self.imin();
326                for j in self.jmin()..self.jmax() {
327                    for k in self.kmin()..=self.kmax() {
328                        if j < self.jmax()
329                            && ic < block.imax
330                            && j + 1 < block.jmax
331                            && k < block.kmax
332                        {
333                            let (x0, y0, z0) = block.xyz(ic, j, k);
334                            let (x1, y1, z1) = block.xyz(ic, j + 1, k);
335                            spacings.push(
336                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
337                            );
338                        }
339                    }
340                }
341                for k in self.kmin()..self.kmax() {
342                    for j in self.jmin()..=self.jmax() {
343                        if k < self.kmax()
344                            && ic < block.imax
345                            && j < block.jmax
346                            && k + 1 < block.kmax
347                        {
348                            let (x0, y0, z0) = block.xyz(ic, j, k);
349                            let (x1, y1, z1) = block.xyz(ic, j, k + 1);
350                            spacings.push(
351                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
352                            );
353                        }
354                    }
355                }
356            }
357            FaceAxis::J => {
358                let jc = self.jmin();
359                for i in self.imin()..self.imax() {
360                    for k in self.kmin()..=self.kmax() {
361                        if i < self.imax()
362                            && i + 1 < block.imax
363                            && jc < block.jmax
364                            && k < block.kmax
365                        {
366                            let (x0, y0, z0) = block.xyz(i, jc, k);
367                            let (x1, y1, z1) = block.xyz(i + 1, jc, k);
368                            spacings.push(
369                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
370                            );
371                        }
372                    }
373                }
374                for k in self.kmin()..self.kmax() {
375                    for i in self.imin()..=self.imax() {
376                        if k < self.kmax()
377                            && i < block.imax
378                            && jc < block.jmax
379                            && k + 1 < block.kmax
380                        {
381                            let (x0, y0, z0) = block.xyz(i, jc, k);
382                            let (x1, y1, z1) = block.xyz(i, jc, k + 1);
383                            spacings.push(
384                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
385                            );
386                        }
387                    }
388                }
389            }
390            FaceAxis::K => {
391                let kc = self.kmin();
392                for i in self.imin()..self.imax() {
393                    for j in self.jmin()..=self.jmax() {
394                        if i < self.imax()
395                            && i + 1 < block.imax
396                            && j < block.jmax
397                            && kc < block.kmax
398                        {
399                            let (x0, y0, z0) = block.xyz(i, j, kc);
400                            let (x1, y1, z1) = block.xyz(i + 1, j, kc);
401                            spacings.push(
402                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
403                            );
404                        }
405                    }
406                }
407                for j in self.jmin()..self.jmax() {
408                    for i in self.imin()..=self.imax() {
409                        if j < self.jmax()
410                            && i < block.imax
411                            && j + 1 < block.jmax
412                            && kc < block.kmax
413                        {
414                            let (x0, y0, z0) = block.xyz(i, j, kc);
415                            let (x1, y1, z1) = block.xyz(i, j + 1, kc);
416                            spacings.push(
417                                ((x1 - x0).powi(2) + (y1 - y0).powi(2) + (z1 - z0).powi(2)).sqrt(),
418                            );
419                        }
420                    }
421                }
422            }
423        }
424        if spacings.is_empty() {
425            return 1.0;
426        }
427        spacings.sort_by(|a, b| a.partial_cmp(b).unwrap());
428        spacings[spacings.len() / 2]
429    }
430
431    /// Compare vertex positions with a tolerance.
432    pub fn vertices_equals(&self, other: &Face, tol: Float) -> bool {
433        if self.vertices.len() != other.vertices.len() {
434            return false;
435        }
436        let mut matched = vec![false; other.vertices.len()];
437        for v in &self.vertices {
438            let mut found = false;
439            for (idx, o) in other.vertices.iter().enumerate() {
440                if matched[idx] {
441                    continue;
442                }
443                // deference and copy the values of v and o
444                if distance(*v, *o) <= tol {
445                    matched[idx] = true;
446                    found = true;
447                    break;
448                }
449            }
450            if !found {
451                return false;
452            }
453        }
454        true
455    }
456
457    /// Structured face points (dense sampling) for node matching.
458    ///
459    /// * `block` - Parent block.
460    /// * `stride_u`, `stride_v` - Sampling strides in parametric space.
461    pub fn grid_points(&self, block: &Block, stride_u: usize, stride_v: usize) -> Vec<[Float; 3]> {
462        let Some(axis) = self.const_axis() else {
463            return self.vertices.clone();
464        };
465        let su = stride_u.max(1);
466        let sv = stride_v.max(1);
467        let mut pts = Vec::new();
468        match axis {
469            FaceAxis::I => {
470                let i = self.imin();
471                for j in (self.jmin()..=self.jmax()).step_by(su) {
472                    for k in (self.kmin()..=self.kmax()).step_by(sv) {
473                        pts.push(to_array(block.xyz(i, j, k)));
474                    }
475                }
476            }
477            FaceAxis::J => {
478                let j = self.jmin();
479                for i in (self.imin()..=self.imax()).step_by(su) {
480                    for k in (self.kmin()..=self.kmax()).step_by(sv) {
481                        pts.push(to_array(block.xyz(i, j, k)));
482                    }
483                }
484            }
485            FaceAxis::K => {
486                let k = self.kmin();
487                for i in (self.imin()..=self.imax()).step_by(su) {
488                    for j in (self.jmin()..=self.jmax()).step_by(sv) {
489                        pts.push(to_array(block.xyz(i, j, k)));
490                    }
491                }
492            }
493        }
494        pts
495    }
496
497    /// Decide if another face shares enough nodes to be considered touching.
498    ///
499    /// * `other` - Candidate face.
500    /// * `block_self`, `block_other` - Parent blocks.
501    /// * `tol_xyz` - Distance tolerance for node equivalence.
502    /// * `min_shared_frac` - Minimum fraction of shared nodes.
503    /// * `min_shared_abs` - Minimum absolute number of shared nodes.
504    /// * `stride_u`, `stride_v` - Sampling stride along the face grid.
505    #[allow(clippy::too_many_arguments)]
506    pub fn touches_by_nodes(
507        &self,
508        other: &Face,
509        block_self: &Block,
510        block_other: &Block,
511        tol_xyz: Float,
512        min_shared_frac: Float,
513        min_shared_abs: usize,
514        stride_u: usize,
515        stride_v: usize,
516    ) -> bool {
517        let pts_self = self.grid_points(block_self, stride_u, stride_v);
518        let pts_other = other.grid_points(block_other, stride_u, stride_v);
519        if pts_self.is_empty() || pts_other.is_empty() {
520            return false;
521        }
522
523        let q_self: HashSet<_> = pts_self
524            .iter()
525            .map(|p| quantize_point(*p, tol_xyz))
526            .collect();
527        let q_other: HashSet<_> = pts_other
528            .iter()
529            .map(|p| quantize_point(*p, tol_xyz))
530            .collect();
531
532        let shared = q_self.intersection(&q_other).count();
533        if shared < min_shared_abs {
534            return false;
535        }
536
537        let denom = pts_self.len().min(pts_other.len()) as Float;
538        (shared as Float) / denom >= min_shared_frac
539    }
540
541    /// Export a [`FaceRecord`] representation mirroring the Python dictionary API.
542    pub fn to_record(&self) -> FaceRecord {
543        FaceRecord {
544            block_index: self.block_index.unwrap_or(usize::MAX),
545            il: self.imin(),
546            jl: self.jmin(),
547            kl: self.kmin(),
548            ih: self.imax(),
549            jh: self.jmax(),
550            kh: self.kmax(),
551            id: self.id,
552            u_physical: None,
553            v_physical: None,
554        }
555    }
556
557    pub fn index_key(&self) -> FaceKey {
558        (
559            self.block_index.unwrap_or(usize::MAX),
560            self.imin(),
561            self.jmin(),
562            self.kmin(),
563            self.imax(),
564            self.jmax(),
565            self.kmax(),
566        )
567    }
568
569    /// Scale all stored index values by `factor`.
570    pub fn scale_indices(&mut self, factor: usize) {
571        if factor <= 1 {
572            return;
573        }
574        for idx in &mut self.indices {
575            idx[0] *= factor;
576            idx[1] *= factor;
577            idx[2] *= factor;
578        }
579    }
580
581    /// True when the face collapses to a single point (all three indices constant).
582    pub fn is_point(&self) -> bool {
583        self.imin() == self.imax() && self.jmin() == self.jmax() && self.kmin() == self.kmax()
584    }
585
586    /// Return parametric face size in index-space cells.
587    ///
588    /// For a face with one constant axis this returns the product of the two
589    /// varying dimension ranges. For a degenerate or 3D region, returns the
590    /// product of all three ranges.
591    pub fn face_size(&self) -> usize {
592        let di = self.imax().saturating_sub(self.imin()).max(1);
593        let dj = self.jmax().saturating_sub(self.jmin()).max(1);
594        let dk = self.kmax().saturating_sub(self.kmin()).max(1);
595        if self.imin() == self.imax() {
596            dj * dk
597        } else if self.jmin() == self.jmax() {
598            di * dk
599        } else if self.kmin() == self.kmax() {
600            di * dj
601        } else {
602            di * dj * dk
603        }
604    }
605
606    /// Compute the (unnormalized) geometric normal using three corner points
607    /// from the parent block, based on which axis is constant.
608    pub fn normal(&self, block: &Block) -> [Float; 3] {
609        let axis = match self.const_axis() {
610            Some(a) => a,
611            None => return [0.0, 0.0, 0.0],
612        };
613
614        let (p1, p2, p3) = match axis {
615            FaceAxis::I => {
616                let ic = self.imin();
617                (
618                    to_array(block.xyz(ic, self.jmin(), self.kmin())),
619                    to_array(block.xyz(ic, self.jmax(), self.kmin())),
620                    to_array(block.xyz(ic, self.jmin(), self.kmax())),
621                )
622            }
623            FaceAxis::J => {
624                let jc = self.jmin();
625                (
626                    to_array(block.xyz(self.imin(), jc, self.kmin())),
627                    to_array(block.xyz(self.imax(), jc, self.kmin())),
628                    to_array(block.xyz(self.imin(), jc, self.kmax())),
629                )
630            }
631            FaceAxis::K => {
632                let kc = self.kmin();
633                (
634                    to_array(block.xyz(self.imin(), self.jmin(), kc)),
635                    to_array(block.xyz(self.imax(), self.jmin(), kc)),
636                    to_array(block.xyz(self.imin(), self.jmax(), kc)),
637                )
638            }
639        };
640
641        cross3(sub3(p2, p1), sub3(p3, p1))
642    }
643
644    /// Shift all stored vertices by `(dx, dy, dz)` in place.
645    pub fn shift(&mut self, dx: Float, dy: Float, dz: Float) {
646        for v in &mut self.vertices {
647            v[0] += dx;
648            v[1] += dy;
649            v[2] += dz;
650        }
651        self.centroid[0] += dx;
652        self.centroid[1] += dy;
653        self.centroid[2] += dz;
654    }
655
656    /// Find vertex-index correspondences between `self` and `other`.
657    ///
658    /// Returns pairs `[i_self, j_other]` where vertex `i_self` of this face
659    /// matches vertex `j_other` of `other` within [`VERTEX_MATCH_TOL`].
660    pub fn match_indices(&self, other: &Face) -> Vec<[usize; 2]> {
661        let tol = VERTEX_MATCH_TOL;
662        let mut matched_other = vec![false; other.vertices.len()];
663        let mut result = Vec::new();
664        for (i, v_self) in self.vertices.iter().enumerate() {
665            for (j, v_other) in other.vertices.iter().enumerate() {
666                if matched_other[j] {
667                    continue;
668                }
669                if (v_self[0] - v_other[0]).abs() < tol
670                    && (v_self[1] - v_other[1]).abs() < tol
671                    && (v_self[2] - v_other[2]).abs() < tol
672                {
673                    result.push([i, j]);
674                    matched_other[j] = true;
675                    break;
676                }
677            }
678        }
679        result
680    }
681
682    /// Compute the overlap area fraction between this face and `other` using
683    /// Sutherland-Hodgman polygon clipping.
684    ///
685    /// Returns `intersection_area / min(area_self, area_other)`.
686    /// Returns 0.0 if normals are not (anti-)parallel within `tol_angle_deg`
687    /// or if the faces are not coplanar within `tol_plane_dist`.
688    pub fn overlap_fraction(
689        &self,
690        other: &Face,
691        tol_angle_deg: Float,
692        tol_plane_dist: Float,
693    ) -> Float {
694        if self.vertices.len() < 3 || other.vertices.len() < 3 {
695            return 0.0;
696        }
697
698        // AABB prefilter: quick rejection if bounding boxes don't overlap
699        let (s_min, s_max) = vertex_aabb(&self.vertices);
700        let (o_min, o_max) = vertex_aabb(&other.vertices);
701        let diag = self
702            .diagonal_length()
703            .max(other.diagonal_length())
704            .max(1e-12);
705        let aabb_tol = tol_plane_dist * diag;
706        if s_max[0] + aabb_tol < o_min[0]
707            || o_max[0] + aabb_tol < s_min[0]
708            || s_max[1] + aabb_tol < o_min[1]
709            || o_max[1] + aabb_tol < s_min[1]
710            || s_max[2] + aabb_tol < o_min[2]
711            || o_max[2] + aabb_tol < s_min[2]
712        {
713            return 0.0;
714        }
715
716        let n1 = quad_normal_from_verts(&self.vertices);
717        let n2 = quad_normal_from_verts(&other.vertices);
718        let len1 = vec_norm3(n1);
719        let len2 = vec_norm3(n2);
720        if len1 < 1e-15 || len2 < 1e-15 {
721            return 0.0;
722        }
723
724        // Check normal parallelism (allow anti-parallel)
725        let cos_angle = dot3(n1, n2) / (len1 * len2);
726        let angle_deg = cos_angle.abs().min(1.0).acos().to_degrees();
727        if angle_deg > tol_angle_deg {
728            return 0.0;
729        }
730
731        // Check coplanarity: all vertices of other within tol of self's plane
732        let p0 = self.vertices[0];
733        let n_hat = [n1[0] / len1, n1[1] / len1, n1[2] / len1];
734        // Adaptive tolerance: scale by face diagonal if needed
735        let diag = self
736            .diagonal_length()
737            .max(other.diagonal_length())
738            .max(1e-12);
739        let plane_tol = tol_plane_dist * diag;
740        for v in &other.vertices {
741            let d = dot3(sub3(*v, p0), n_hat).abs();
742            if d > plane_tol {
743                return 0.0;
744            }
745        }
746
747        // Project to 2D
748        let drop = dominant_projection_axis(n_hat);
749        let poly_self = project_drop_axis(&self.vertices, drop);
750        let poly_other = project_drop_axis(&other.vertices, drop);
751
752        let area_self = poly_area_2d(&poly_self).abs();
753        let area_other = poly_area_2d(&poly_other).abs();
754        if area_self < 1e-30 || area_other < 1e-30 {
755            return 0.0;
756        }
757
758        let clipped = clip_sutherland_hodgman(&poly_other, &poly_self);
759        if clipped.len() < 3 {
760            return 0.0;
761        }
762
763        let area_inter = poly_area_2d(&clipped).abs();
764        area_inter / area_self.min(area_other)
765    }
766
767    /// Returns `true` if `overlap_fraction >= min_overlap_frac`.
768    pub fn touches(
769        &self,
770        other: &Face,
771        tol_angle_deg: Float,
772        tol_plane_dist: Float,
773        min_overlap_frac: Float,
774    ) -> bool {
775        self.overlap_fraction(other, tol_angle_deg, tol_plane_dist) >= min_overlap_frac
776    }
777
778    /// Fraction of shared grid nodes between this face and `other`.
779    ///
780    /// Uses quantized point comparison for robustness.
781    pub fn shared_point_fraction(
782        &self,
783        other: &Face,
784        block_self: &Block,
785        block_other: &Block,
786        tol_xyz: Float,
787        stride_u: usize,
788        stride_v: usize,
789    ) -> Float {
790        let pts_self = self.grid_points(block_self, stride_u, stride_v);
791        let pts_other = other.grid_points(block_other, stride_u, stride_v);
792        if pts_self.is_empty() || pts_other.is_empty() {
793            return 0.0;
794        }
795
796        let q_self: HashSet<_> = pts_self
797            .iter()
798            .map(|p| quantize_point(*p, tol_xyz))
799            .collect();
800        let q_other: HashSet<_> = pts_other
801            .iter()
802            .map(|p| quantize_point(*p, tol_xyz))
803            .collect();
804
805        let shared = q_self.intersection(&q_other).count();
806        let denom = pts_self.len().min(pts_other.len()) as Float;
807        if denom == 0.0 {
808            return 0.0;
809        }
810        (shared as Float) / denom
811    }
812}
813
814/// Helper structure representing a structured face grid.
815/// Dense representation of a structured face grid.
816#[derive(Clone, Debug)]
817pub struct StructuredFace {
818    /// Face dimensions `(nu, nv)`.
819    pub dims: (usize, usize),
820    /// Flattened coordinates stored row-major in `u`.
821    pub coords: Vec<[Float; 3]>,
822}
823
824impl StructuredFace {
825    fn idx(&self, u: usize, v: usize) -> [Float; 3] {
826        self.coords[v * self.dims.0 + u]
827    }
828}
829
830#[derive(Copy, Clone, Debug)]
831enum BlockFaceKind {
832    IMin,
833    IMax,
834    JMin,
835    JMax,
836    KMin,
837    KMax,
838}
839
840impl BlockFaceKind {
841    fn all() -> [Self; 6] {
842        [
843            Self::IMin,
844            Self::IMax,
845            Self::JMin,
846            Self::JMax,
847            Self::KMin,
848            Self::KMax,
849        ]
850    }
851
852    fn name(self) -> &'static str {
853        match self {
854            Self::IMin => "imin",
855            Self::IMax => "imax",
856            Self::JMin => "jmin",
857            Self::JMax => "jmax",
858            Self::KMin => "kmin",
859            Self::KMax => "kmax",
860        }
861    }
862
863    fn dims(self, block: &Block) -> (usize, usize) {
864        match self {
865            Self::IMin | Self::IMax => (block.jmax, block.kmax),
866            Self::JMin | Self::JMax => (block.imax, block.kmax),
867            Self::KMin | Self::KMax => (block.imax, block.jmax),
868        }
869    }
870
871    fn sample(self, block: &Block, u: usize, v: usize) -> [Float; 3] {
872        match self {
873            Self::IMin => to_array(block.xyz(0, u, v)),
874            Self::IMax => to_array(block.xyz(block.imax - 1, u, v)),
875            Self::JMin => to_array(block.xyz(u, 0, v)),
876            Self::JMax => to_array(block.xyz(u, block.jmax - 1, v)),
877            Self::KMin => to_array(block.xyz(u, v, 0)),
878            Self::KMax => to_array(block.xyz(u, v, block.kmax - 1)),
879        }
880    }
881
882    fn structured_face(self, block: &Block) -> StructuredFace {
883        let dims = self.dims(block);
884        let mut coords = Vec::with_capacity(dims.0 * dims.1);
885        for v in 0..dims.1 {
886            for u in 0..dims.0 {
887                coords.push(self.sample(block, u, v));
888            }
889        }
890        StructuredFace { dims, coords }
891    }
892}
893
894// Geometry functions (distance, quantize_point, to_array, quad_normal_from_verts,
895// vertex_aabb, dominant_projection_axis, project_drop_axis, poly_area_2d,
896// clip_sutherland_hodgman) are in crate::geometry.
897
898/// Deduplicate index pairs (order-agnostic).
899///
900/// # Arguments
901/// * `pairs` - Candidate index tuples `(a, b)`.
902///
903/// # Returns
904/// Deduplicated list preserving the original ordering of the input.
905pub fn unique_pairs(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
906    let mut seen = HashSet::new();
907    let mut out = Vec::new();
908    for &(a, b) in pairs {
909        if a == b {
910            continue;
911        }
912        let key = if a < b { (a, b) } else { (b, a) };
913        if seen.insert(key) {
914            out.push((a, b));
915        }
916    }
917    out
918}
919
920/// Compare two structured faces and determine whether they match.
921///
922/// # Arguments
923/// * `face1` - Face sampled from the first block.
924/// * `face2` - Face sampled from the second block.
925/// * `tol` - Maximum Euclidean distance allowed between corner nodes.
926///
927/// # Returns
928/// `(matches, flips)` where `flips` encodes `(flip_ud, flip_lr)` applied to `face2`.
929pub fn faces_match(
930    face1: &StructuredFace,
931    face2: &StructuredFace,
932    tol: Float,
933) -> (bool, Option<(bool, bool)>) {
934    if face1.dims != face2.dims {
935        return (false, None);
936    }
937    let (ni, nj) = face1.dims;
938
939    let corners = |f: &StructuredFace, flip_ud: bool, flip_lr: bool| -> [[Float; 3]; 4] {
940        let map = |u: usize, v: usize| {
941            let uu = if flip_ud { ni - 1 - u } else { u };
942            let vv = if flip_lr { nj - 1 - v } else { v };
943            f.idx(uu, vv)
944        };
945        [
946            map(0, 0),
947            map(0, nj - 1),
948            map(ni - 1, 0),
949            map(ni - 1, nj - 1),
950        ]
951    };
952
953    let c1 = corners(face1, false, false);
954    for flip_ud in [false, true] {
955        for flip_lr in [false, true] {
956            let c2 = corners(face2, flip_ud, flip_lr);
957            if c1.iter().zip(&c2).all(|(a, b)| distance(*a, *b) <= tol) {
958                return (true, Some((flip_ud, flip_lr)));
959            }
960        }
961    }
962    (false, None)
963}
964
965/// Attempt a full (1:1) face match by comparing the 4 corner vertices.
966///
967/// Tries all 8 valid orientations (4 flip combinations + swap) of face_b's
968/// corners against face_a's canonical corner ordering.  A match means all 4
969/// corners are within `tol` of each other.
970///
971/// # Returns
972/// `Some(Orientation)` describing how face_b's indices map to face_a's,
973/// or `None` if no valid corner mapping exists within tolerance.
974pub fn full_face_match(
975    face_a: &Face,
976    face_b: &Face,
977    tol: Float,
978) -> Option<crate::face_record::Orientation> {
979    let corners_a = face_a.get_all_corners()?;
980    let corners_b = face_b.get_all_corners()?;
981    try_corner_permutations(&corners_a, &corners_b, tol)
982}
983
984/// Like [`full_face_match`] but applies a coordinate transformation to
985/// face_a's corners before comparing.
986///
987/// `transform` maps `[Float; 3] -> [Float; 3]`, typically a rotation or
988/// translation.
989pub fn full_face_match_transformed<F>(
990    face_a: &Face,
991    face_b: &Face,
992    transform: F,
993    tol: Float,
994) -> Option<crate::face_record::Orientation>
995where
996    F: Fn([Float; 3]) -> [Float; 3],
997{
998    let raw = face_a.get_all_corners()?;
999    let corners_a = [
1000        transform(raw[0]),
1001        transform(raw[1]),
1002        transform(raw[2]),
1003        transform(raw[3]),
1004    ];
1005    let corners_b = face_b.get_all_corners()?;
1006    try_corner_permutations(&corners_a, &corners_b, tol)
1007}
1008
1009/// Core logic: try all 8 orientation permutations of `cb` against `ca`.
1010///
1011/// The canonical corner ordering is:
1012///   `[0] = (u_min, v_min)`
1013///   `[1] = (u_min, v_max)`
1014///   `[2] = (u_max, v_min)`
1015///   `[3] = (u_max, v_max)`
1016fn try_corner_permutations(
1017    ca: &[[Float; 3]; 4],
1018    cb: &[[Float; 3]; 4],
1019    tol: Float,
1020) -> Option<crate::face_record::Orientation> {
1021    use crate::face_record::{Orientation, OrientationPlane};
1022
1023    // Corner index permutations corresponding to each of the 8 orientation matrices.
1024    // Canonical corner ordering: [0]=(u_min,v_min), [1]=(u_min,v_max),
1025    //                            [2]=(u_max,v_min), [3]=(u_max,v_max)
1026    const CORNER_PERMS: [[usize; 4]; 8] = [
1027        [0, 1, 2, 3], // 0: identity
1028        [2, 3, 0, 1], // 1: u reversed
1029        [1, 0, 3, 2], // 2: v reversed
1030        [3, 2, 1, 0], // 3: both reversed
1031        [0, 2, 1, 3], // 4: swapped
1032        [2, 0, 3, 1], // 5: swap + u_reversed
1033        [1, 3, 0, 2], // 6: swap + v_reversed
1034        [3, 1, 2, 0], // 7: swap + both
1035    ];
1036
1037    for (idx, perm) in CORNER_PERMS.iter().enumerate() {
1038        let all_match = perm
1039            .iter()
1040            .enumerate()
1041            .all(|(i, &j)| distance(ca[i], cb[j]) <= tol);
1042        if all_match {
1043            return Some(Orientation {
1044                permutation_index: idx as u8,
1045                plane: if idx >= 4 {
1046                    OrientationPlane::CrossPlane
1047                } else {
1048                    OrientationPlane::InPlane
1049                },
1050            });
1051        }
1052    }
1053
1054    None
1055}
1056
1057/// Determine whether any faces on two blocks match.
1058///
1059/// # Arguments
1060/// * `block1` - First block to compare.
1061/// * `block2` - Second block to compare.
1062/// * `tol` - Corner matching tolerance.
1063///
1064/// # Returns
1065/// `Some((face_name_block1, face_name_block2, (flip_ud, flip_lr)))` when matching faces are found.
1066pub fn find_matching_faces(
1067    block1: &Block,
1068    block2: &Block,
1069    tol: Float,
1070) -> Option<(&'static str, &'static str, (bool, bool))> {
1071    for f1 in BlockFaceKind::all() {
1072        let face1 = f1.structured_face(block1);
1073        for f2 in BlockFaceKind::all() {
1074            let face2 = f2.structured_face(block2);
1075            let (matched, flips) = faces_match(&face1, &face2, tol);
1076            if matched {
1077                return flips.map(|flip| (f1.name(), f2.name(), flip));
1078            }
1079        }
1080    }
1081    None
1082}
1083
1084/// Build the six outer faces for a block and identify internal matches.
1085///
1086/// # Arguments
1087/// * `block` - Target plot3d block.
1088///
1089/// # Returns
1090/// Tuple containing the exterior faces and any internal matching face pairs.
1091pub fn get_outer_faces(block: &Block) -> (Vec<Face>, Vec<(Face, Face)>) {
1092    let mut faces = Vec::with_capacity(6);
1093    for kind in BlockFaceKind::all() {
1094        let mut face = Face::new();
1095        match kind {
1096            BlockFaceKind::IMin | BlockFaceKind::IMax => {
1097                let i = if matches!(kind, BlockFaceKind::IMin) {
1098                    0
1099                } else {
1100                    block.imax - 1
1101                };
1102                for j in [0, block.jmax - 1] {
1103                    for k in [0, block.kmax - 1] {
1104                        let (x, y, z) = block.xyz(i, j, k);
1105                        face.add_vertex(x, y, z, i, j, k);
1106                    }
1107                }
1108            }
1109            BlockFaceKind::JMin | BlockFaceKind::JMax => {
1110                let j = if matches!(kind, BlockFaceKind::JMin) {
1111                    0
1112                } else {
1113                    block.jmax - 1
1114                };
1115                for i in [0, block.imax - 1] {
1116                    for k in [0, block.kmax - 1] {
1117                        let (x, y, z) = block.xyz(i, j, k);
1118                        face.add_vertex(x, y, z, i, j, k);
1119                    }
1120                }
1121            }
1122            BlockFaceKind::KMin | BlockFaceKind::KMax => {
1123                let k = if matches!(kind, BlockFaceKind::KMin) {
1124                    0
1125                } else {
1126                    block.kmax - 1
1127                };
1128                for i in [0, block.imax - 1] {
1129                    for j in [0, block.jmax - 1] {
1130                        let (x, y, z) = block.xyz(i, j, k);
1131                        face.add_vertex(x, y, z, i, j, k);
1132                    }
1133                }
1134            }
1135        }
1136        faces.push(face);
1137    }
1138
1139    let mut matching_pairs = Vec::new();
1140    let mut non_matching = Vec::new();
1141    for i in 0..faces.len() {
1142        let mut matched = false;
1143        for j in 0..faces.len() {
1144            if i == j {
1145                continue;
1146            }
1147            if faces[i].vertices_equals(&faces[j], DEFAULT_TOL) {
1148                matching_pairs.push((i, j));
1149                matched = true;
1150            }
1151        }
1152        if !matched {
1153            non_matching.push(faces[i].clone());
1154        }
1155    }
1156
1157    let pairs = unique_pairs(&matching_pairs)
1158        .into_iter()
1159        .map(|(a, b)| (faces[a].clone(), faces[b].clone()))
1160        .collect();
1161
1162    (non_matching, pairs)
1163}
1164
1165/// Build a face from diagonal index pairs on a block.
1166///
1167/// # Arguments
1168/// * `block` - Parent block.
1169/// * `imin`, `jmin`, `kmin` - Lower corner indices.
1170/// * `imax`, `jmax`, `kmax` - Upper corner indices.
1171///
1172/// # Returns
1173/// New `Face` populated with the four corner nodes.
1174pub fn create_face_from_diagonals(
1175    block: &Block,
1176    imin: usize,
1177    jmin: usize,
1178    kmin: usize,
1179    imax: usize,
1180    jmax: usize,
1181    kmax: usize,
1182) -> Face {
1183    let mut face = Face::new();
1184    if imin == imax {
1185        let i = imin;
1186        for j in [jmin, jmax] {
1187            for k in [kmin, kmax] {
1188                let (x, y, z) = block.xyz(i, j, k);
1189                face.add_vertex(x, y, z, i, j, k);
1190            }
1191        }
1192    } else if jmin == jmax {
1193        let j = jmin;
1194        for i in [imin, imax] {
1195            for k in [kmin, kmax] {
1196                let (x, y, z) = block.xyz(i, j, k);
1197                face.add_vertex(x, y, z, i, j, k);
1198            }
1199        }
1200    } else if kmin == kmax {
1201        let k = kmin;
1202        for i in [imin, imax] {
1203            for j in [jmin, jmax] {
1204                let (x, y, z) = block.xyz(i, j, k);
1205                face.add_vertex(x, y, z, i, j, k);
1206            }
1207        }
1208    }
1209    face
1210}
1211
1212/// Convert serialized face records back into `Face` instances.
1213///
1214/// # Arguments
1215/// * `blocks` - Blocks interpreted at the reduced resolution.
1216/// * `outer_faces` - Collection of serialized face records.
1217/// * `gcd` - Grid reduction factor applied to the blocks.
1218///
1219/// # Returns
1220/// Converted faces with block indices preserved.
1221pub fn outer_face_records_to_list(
1222    blocks: &[Block],
1223    outer_faces: &[FaceRecord],
1224    gcd: usize,
1225) -> Vec<Face> {
1226    let mut faces = Vec::new();
1227    for record in outer_faces {
1228        let block_idx = record.block_index;
1229        if block_idx >= blocks.len() {
1230            continue;
1231        }
1232        let block = &blocks[block_idx];
1233        let scale = gcd.max(1);
1234        let (si, sj, sk) = (
1235            record.i_lo() / scale,
1236            record.j_lo() / scale,
1237            record.k_lo() / scale,
1238        );
1239        let (ei, ej, ek) = (
1240            record.i_hi() / scale,
1241            record.j_hi() / scale,
1242            record.k_hi() / scale,
1243        );
1244        // Skip records whose scaled indices exceed the reduced block dimensions
1245        if ei >= block.imax || ej >= block.jmax || ek >= block.kmax {
1246            continue;
1247        }
1248        let mut face = create_face_from_diagonals(block, si, sj, sk, ei, ej, ek);
1249        face.set_block_index(block_idx);
1250        if let Some(id) = record.id {
1251            face.set_id(id);
1252        }
1253        faces.push(face);
1254    }
1255    faces
1256}
1257
1258/// Convert serialized matched faces to a flat `Face` list.
1259///
1260/// # Arguments
1261/// * `blocks` - Blocks interpreted at the reduced resolution.
1262/// * `matched_faces` - Matched face descriptors describing interfaces.
1263/// * `gcd` - Grid reduction factor applied to the blocks.
1264///
1265/// # Returns
1266/// Flattened list of faces representing every entry in `matched_faces`.
1267pub fn match_faces_to_list(blocks: &[Block], matched_faces: &[FaceMatch], gcd: usize) -> Vec<Face> {
1268    let mut out = Vec::new();
1269    for record in matched_faces {
1270        let f1 = outer_face_records_to_list(blocks, std::slice::from_ref(&record.block1), gcd)
1271            .into_iter()
1272            .next();
1273        let f2 = outer_face_records_to_list(blocks, std::slice::from_ref(&record.block2), gcd)
1274            .into_iter()
1275            .next();
1276        if let Some(face) = f1 {
1277            out.push(face);
1278        }
1279        if let Some(face) = f2 {
1280            out.push(face);
1281        }
1282    }
1283    out
1284}
1285
1286/// Split a face into subfaces along the specified diagonal indices.
1287///
1288/// # Arguments
1289/// * `face_to_split` - Parent face to subdivide.
1290/// * `block` - Block providing geometry.
1291/// * `imin`, `jmin`, `kmin` - Lower split indices.
1292/// * `imax`, `jmax`, `kmax` - Upper split indices.
1293///
1294/// # Returns
1295/// Collection of child faces excluding edges and the centre face itself.
1296#[allow(clippy::too_many_arguments)]
1297pub fn split_face(
1298    face_to_split: &Face,
1299    block: &Block,
1300    imin: usize,
1301    jmin: usize,
1302    kmin: usize,
1303    imax: usize,
1304    jmax: usize,
1305    kmax: usize,
1306) -> Vec<Face> {
1307    let center = create_face_from_diagonals(block, imin, jmin, kmin, imax, jmax, kmax);
1308    let mut faces = Vec::new();
1309
1310    if kmin == kmax {
1311        faces.push(create_face_from_diagonals(
1312            block,
1313            imin,
1314            jmax,
1315            kmin,
1316            imax,
1317            face_to_split.jmax(),
1318            kmax,
1319        ));
1320        faces.push(create_face_from_diagonals(
1321            block,
1322            imin,
1323            face_to_split.jmin(),
1324            kmin,
1325            imax,
1326            jmin,
1327            kmax,
1328        ));
1329        faces.push(create_face_from_diagonals(
1330            block,
1331            face_to_split.imin(),
1332            face_to_split.jmin(),
1333            kmin,
1334            imin,
1335            face_to_split.jmax(),
1336            kmax,
1337        ));
1338        faces.push(create_face_from_diagonals(
1339            block,
1340            imax,
1341            face_to_split.jmin(),
1342            kmin,
1343            face_to_split.imax(),
1344            face_to_split.jmax(),
1345            kmax,
1346        ));
1347    } else if imin == imax {
1348        faces.push(create_face_from_diagonals(
1349            block,
1350            imin,
1351            jmin,
1352            kmax,
1353            imax,
1354            jmax,
1355            face_to_split.kmax(),
1356        ));
1357        faces.push(create_face_from_diagonals(
1358            block,
1359            imin,
1360            jmin,
1361            face_to_split.kmin(),
1362            imax,
1363            jmax,
1364            kmin,
1365        ));
1366        faces.push(create_face_from_diagonals(
1367            block,
1368            imin,
1369            face_to_split.jmin(),
1370            face_to_split.kmin(),
1371            imax,
1372            jmin,
1373            face_to_split.kmax(),
1374        ));
1375        faces.push(create_face_from_diagonals(
1376            block,
1377            imin,
1378            jmax,
1379            face_to_split.kmin(),
1380            imax,
1381            face_to_split.jmax(),
1382            face_to_split.kmax(),
1383        ));
1384    } else if jmin == jmax {
1385        faces.push(create_face_from_diagonals(
1386            block,
1387            imin,
1388            jmin,
1389            kmax,
1390            imax,
1391            jmax,
1392            face_to_split.kmax(),
1393        ));
1394        faces.push(create_face_from_diagonals(
1395            block,
1396            imin,
1397            jmin,
1398            face_to_split.kmin(),
1399            imax,
1400            jmax,
1401            kmin,
1402        ));
1403        faces.push(create_face_from_diagonals(
1404            block,
1405            face_to_split.imin(),
1406            jmin,
1407            face_to_split.kmin(),
1408            imin,
1409            jmax,
1410            face_to_split.kmax(),
1411        ));
1412        faces.push(create_face_from_diagonals(
1413            block,
1414            imax,
1415            jmin,
1416            face_to_split.kmin(),
1417            face_to_split.imax(),
1418            jmax,
1419            face_to_split.kmax(),
1420        ));
1421    }
1422
1423    faces
1424        .into_iter()
1425        .filter_map(|mut face| {
1426            if face.is_edge() || face.index_equals(&center) {
1427                None
1428            } else {
1429                if let Some(idx) = face_to_split.block_index() {
1430                    face.set_block_index(idx);
1431                }
1432                Some(face)
1433            }
1434        })
1435        .collect()
1436}
1437
1438/// Pick the face closest to a reference point.
1439///
1440/// # Arguments
1441/// * `faces` - Candidate faces.
1442/// * `point` - Cartesian reference location.
1443///
1444/// # Returns
1445/// Index of the nearest face or `None` when the list is empty.
1446pub fn find_face_nearest_point(faces: &[Face], point: [Float; 3]) -> Option<usize> {
1447    faces
1448        .iter()
1449        .enumerate()
1450        .min_by(|(_, a), (_, b)| {
1451            distance(a.centroid(), point)
1452                .partial_cmp(&distance(b.centroid(), point))
1453                .unwrap_or(std::cmp::Ordering::Equal)
1454        })
1455        .map(|(idx, _)| idx)
1456}
1457
1458/// Reduce blocks by sampling every `factor` nodes along each axis.
1459///
1460/// # Arguments
1461/// * `blocks` - Blocks to down-sample.
1462/// * `factor` - Sampling step applied to i, j and k directions.
1463///
1464/// # Returns
1465/// New blocks reduced to consistent spacing.
1466pub fn reduce_blocks(blocks: &[Block], factor: usize) -> Vec<Block> {
1467    if factor <= 1 {
1468        return blocks.to_vec();
1469    }
1470
1471    fn sampled_indices(max: usize, stride: usize) -> Vec<usize> {
1472        if max == 0 {
1473            return Vec::new();
1474        }
1475        let mut indices: Vec<usize> = (0..max).step_by(stride).collect();
1476        if let Some(&last) = indices.last() {
1477            if last != max - 1 {
1478                indices.push(max - 1);
1479            }
1480        } else {
1481            indices.push(max - 1);
1482        }
1483        indices
1484    }
1485
1486    blocks
1487        .iter()
1488        .map(|block| {
1489            let i_idx = sampled_indices(block.imax, factor);
1490            let j_idx = sampled_indices(block.jmax, factor);
1491            let k_idx = sampled_indices(block.kmax, factor);
1492
1493            let si = i_idx.len();
1494            let sj = j_idx.len();
1495            let sk = k_idx.len();
1496
1497            let mut x = Vec::with_capacity(si * sj * sk);
1498            let mut y = Vec::with_capacity(si * sj * sk);
1499            let mut z = Vec::with_capacity(si * sj * sk);
1500
1501            for &k in &k_idx {
1502                for &j in &j_idx {
1503                    for &i in &i_idx {
1504                        let (px, py, pz) = block.xyz(i, j, k);
1505                        x.push(px);
1506                        y.push(py);
1507                        z.push(pz);
1508                    }
1509                }
1510            }
1511
1512            Block::new(si, sj, sk, x, y, z)
1513        })
1514        .collect()
1515}
1516
1517/// Rotate a block using a 3×3 rotation matrix.
1518///
1519/// # Arguments
1520/// * `block` - Block to rotate.
1521/// * `rotation` - Row-major rotation matrix.
1522///
1523/// # Returns
1524/// Rotated block with identical dimensions.
1525pub fn rotate_block(block: &Block, rotation: [[Float; 3]; 3]) -> Block {
1526    let mut x = Vec::with_capacity(block.npoints());
1527    let mut y = Vec::with_capacity(block.npoints());
1528    let mut z = Vec::with_capacity(block.npoints());
1529    for k in 0..block.kmax {
1530        for j in 0..block.jmax {
1531            for i in 0..block.imax {
1532                let (px, py, pz) = block.xyz(i, j, k);
1533                x.push(rotation[0][0] * px + rotation[0][1] * py + rotation[0][2] * pz);
1534                y.push(rotation[1][0] * px + rotation[1][1] * py + rotation[1][2] * pz);
1535                z.push(rotation[2][0] * px + rotation[2][1] * py + rotation[2][2] * pz);
1536            }
1537        }
1538    }
1539    Block::new(block.imax, block.jmax, block.kmax, x, y, z)
1540}
1541
1542// Block-level analysis functions (get_outer_bounds, block_connection_matrix,
1543// standardize_block_orientation, check_collinearity, calculate_outward_normals,
1544// find_bounding_faces, find_closest_block, common_neighbor,
1545// build_connectivity_graph) are in crate::block_analysis.
1546
1547// Cylindrical coordinate helpers (to_theta, to_radius, find_angular_bounding_faces)
1548// are in crate::cylindrical.