Skip to main content

plot3d/
face_pool.rs

1//! Cylindrical-coordinate face pool and edge matching utilities for
2//! rotational periodicity detection.
3
4use std::collections::HashSet;
5
6use crate::{
7    block::Block,
8    block_face_functions::{Face, FaceAxis},
9    cylindrical::{to_radius, to_theta},
10    face_record::{FaceKey, FaceRecord},
11    utils::{apply_rotation, distance3},
12    Float, PI,
13};
14
15/// Extract the axial coordinate (along the rotation axis) from a 3D point.
16#[inline]
17pub(crate) fn axial_coord(p: [Float; 3], rotation_axis: char) -> Float {
18    match rotation_axis.to_ascii_lowercase() {
19        'x' => p[0],
20        'y' => p[1],
21        _ => p[2],
22    }
23}
24
25/// Precomputed cylindrical-coordinate metadata for a single face in the pool.
26#[derive(Clone, Debug)]
27#[allow(dead_code)]
28pub(crate) struct CylindricalFaceInfo {
29    pub theta_centroid: Float,
30    pub axial_centroid: Float,
31    pub radial_centroid: Float,
32    pub theta_min: Float,
33    pub theta_max: Float,
34    pub axial_min: Float,
35    pub axial_max: Float,
36    pub radial_min: Float,
37    pub radial_max: Float,
38}
39
40impl CylindricalFaceInfo {
41    pub fn from_face(face: &Face, rotation_axis: char) -> Self {
42        let verts = face.vertices();
43        let c = face.centroid();
44        let theta_c = to_theta(c[0], c[1], c[2], rotation_axis);
45        let axial_c = axial_coord(c, rotation_axis);
46        let radial_c = to_radius(c[0], c[1], c[2], rotation_axis);
47
48        let mut theta_min = theta_c;
49        let mut theta_max = theta_c;
50        let mut axial_min = axial_c;
51        let mut axial_max = axial_c;
52        let mut radial_min = radial_c;
53        let mut radial_max = radial_c;
54
55        for v in verts {
56            let th = to_theta(v[0], v[1], v[2], rotation_axis);
57            let ax = axial_coord(*v, rotation_axis);
58            let r = to_radius(v[0], v[1], v[2], rotation_axis);
59            theta_min = theta_min.min(th);
60            theta_max = theta_max.max(th);
61            axial_min = axial_min.min(ax);
62            axial_max = axial_max.max(ax);
63            radial_min = radial_min.min(r);
64            radial_max = radial_max.max(r);
65        }
66
67        Self {
68            theta_centroid: theta_c,
69            axial_centroid: axial_c,
70            radial_centroid: radial_c,
71            theta_min,
72            theta_max,
73            axial_min,
74            axial_max,
75            radial_min,
76            radial_max,
77        }
78    }
79}
80
81/// A 1D line of grid points along one boundary of a structured face.
82#[derive(Clone, Debug)]
83pub(crate) struct StructuredEdge {
84    pub coords: Vec<[Float; 3]>,
85}
86
87/// Result of comparing two structured edges (one rotated).
88#[derive(Debug)]
89pub(crate) enum EdgeMatchResult {
90    None,
91    Full,
92    Partial,
93}
94
95/// Managed pool of outer faces with cylindrical-coordinate lookup.
96pub(crate) struct FacePool {
97    pub faces: Vec<Face>,
98    cyl_info: Vec<CylindricalFaceInfo>,
99    /// Indices into `faces` sorted by theta_centroid (ascending).
100    theta_sorted: Vec<usize>,
101    consumed: HashSet<FaceKey>,
102    rotation_axis: char,
103}
104
105impl FacePool {
106    pub fn new(faces: Vec<Face>, rotation_axis: char) -> Self {
107        let cyl_info: Vec<CylindricalFaceInfo> = faces
108            .iter()
109            .map(|f| CylindricalFaceInfo::from_face(f, rotation_axis))
110            .collect();
111        let mut theta_sorted: Vec<usize> = (0..faces.len()).collect();
112        theta_sorted.sort_by(|&a, &b| {
113            cyl_info[a]
114                .theta_centroid
115                .partial_cmp(&cyl_info[b].theta_centroid)
116                .unwrap_or(std::cmp::Ordering::Equal)
117        });
118        Self {
119            faces,
120            cyl_info,
121            theta_sorted,
122            consumed: HashSet::new(),
123            rotation_axis,
124        }
125    }
126
127    pub fn add_face(&mut self, face: Face) {
128        let info = CylindricalFaceInfo::from_face(&face, self.rotation_axis);
129        let idx = self.faces.len();
130        self.faces.push(face);
131        self.cyl_info.push(info);
132        // Insert into sorted list at correct position
133        let theta = self.cyl_info[idx].theta_centroid;
134        let pos = self
135            .theta_sorted
136            .binary_search_by(|&i| {
137                self.cyl_info[i]
138                    .theta_centroid
139                    .partial_cmp(&theta)
140                    .unwrap_or(std::cmp::Ordering::Equal)
141            })
142            .unwrap_or_else(|p| p);
143        self.theta_sorted.insert(pos, idx);
144    }
145
146    pub fn consume(&mut self, key: FaceKey) {
147        self.consumed.insert(key);
148    }
149
150    pub fn is_consumed(&self, idx: usize) -> bool {
151        let face = &self.faces[idx];
152        self.consumed.contains(&face.index_key())
153    }
154
155    /// Find face indices whose cylindrical extents could overlap with the given
156    /// target theta and matching axial/radial ranges.
157    pub fn find_candidates(
158        &self,
159        target_theta: Float,
160        axial_range: (Float, Float),
161        radial_range: (Float, Float),
162        theta_tol: Float,
163    ) -> Vec<usize> {
164        let theta_lo = target_theta - theta_tol;
165        let theta_hi = target_theta + theta_tol;
166
167        let mut candidates = Vec::new();
168
169        // Binary search for the starting position in sorted theta
170        let start = self
171            .theta_sorted
172            .binary_search_by(|&i| {
173                self.cyl_info[i]
174                    .theta_centroid
175                    .partial_cmp(&theta_lo)
176                    .unwrap_or(std::cmp::Ordering::Equal)
177            })
178            .unwrap_or_else(|p| p);
179
180        // Scan forward from start
181        for &pool_idx in &self.theta_sorted[start..] {
182            let info = &self.cyl_info[pool_idx];
183            if info.theta_centroid > theta_hi {
184                break;
185            }
186            if self.is_consumed(pool_idx) {
187                continue;
188            }
189            // Check axial overlap
190            if info.axial_max
191                < axial_range.0 - 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
192                || info.axial_min
193                    > axial_range.1 + 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
194            {
195                continue;
196            }
197            // Check radial overlap
198            if info.radial_max
199                < radial_range.0 - 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
200                || info.radial_min
201                    > radial_range.1 + 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
202            {
203                continue;
204            }
205            candidates.push(pool_idx);
206        }
207
208        // Handle theta wrapping at ±π boundary
209        // If theta_lo < -π, also search near +π end
210        if theta_lo < -PI {
211            let wrapped_lo = theta_lo + 2.0 * PI;
212            let wrapped_hi = PI; // search from wrapped_lo to +π
213            let ws = self
214                .theta_sorted
215                .binary_search_by(|&i| {
216                    self.cyl_info[i]
217                        .theta_centroid
218                        .partial_cmp(&wrapped_lo)
219                        .unwrap_or(std::cmp::Ordering::Equal)
220                })
221                .unwrap_or_else(|p| p);
222            for &pool_idx in &self.theta_sorted[ws..] {
223                let info = &self.cyl_info[pool_idx];
224                if info.theta_centroid > wrapped_hi {
225                    break;
226                }
227                if self.is_consumed(pool_idx) {
228                    continue;
229                }
230                if info.axial_max
231                    < axial_range.0 - 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
232                    || info.axial_min
233                        > axial_range.1 + 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
234                {
235                    continue;
236                }
237                if info.radial_max
238                    < radial_range.0 - 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
239                    || info.radial_min
240                        > radial_range.1 + 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
241                {
242                    continue;
243                }
244                candidates.push(pool_idx);
245            }
246        }
247        // If theta_hi > π, also search near -π end
248        if theta_hi > PI {
249            let wrapped_hi = theta_hi - 2.0 * PI;
250            let wrapped_lo = -PI;
251            let ws = self
252                .theta_sorted
253                .binary_search_by(|&i| {
254                    self.cyl_info[i]
255                        .theta_centroid
256                        .partial_cmp(&wrapped_lo)
257                        .unwrap_or(std::cmp::Ordering::Equal)
258                })
259                .unwrap_or_else(|p| p);
260            for &pool_idx in &self.theta_sorted[ws..] {
261                let info = &self.cyl_info[pool_idx];
262                if info.theta_centroid > wrapped_hi {
263                    break;
264                }
265                if self.is_consumed(pool_idx) {
266                    continue;
267                }
268                if info.axial_max
269                    < axial_range.0 - 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
270                    || info.axial_min
271                        > axial_range.1 + 0.1 * (axial_range.1 - axial_range.0).abs().max(1e-12)
272                {
273                    continue;
274                }
275                if info.radial_max
276                    < radial_range.0 - 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
277                    || info.radial_min
278                        > radial_range.1 + 0.1 * (radial_range.1 - radial_range.0).abs().max(1e-12)
279                {
280                    continue;
281                }
282                candidates.push(pool_idx);
283            }
284        }
285
286        candidates
287    }
288
289    /// Find candidates for a face at the given index by searching both +angle and -angle
290    /// theta targets, deduplicating the results.
291    pub fn find_rotational_candidates(
292        &self,
293        idx: usize,
294        rotation_angle: Float,
295        theta_tol: Float,
296    ) -> Vec<usize> {
297        let info = &self.cyl_info[idx];
298        let target_fwd = info.theta_centroid + rotation_angle;
299        let target_rev = info.theta_centroid - rotation_angle;
300        let axial_range = (info.axial_min, info.axial_max);
301        let radial_range = (info.radial_min, info.radial_max);
302        let mut candidates = self.find_candidates(target_fwd, axial_range, radial_range, theta_tol);
303        candidates.extend(self.find_candidates(target_rev, axial_range, radial_range, theta_tol));
304        candidates.sort_unstable();
305        candidates.dedup();
306        candidates
307    }
308
309    /// Collect all unconsumed face indices.
310    pub fn active_indices(&self) -> Vec<usize> {
311        (0..self.faces.len())
312            .filter(|&i| !self.is_consumed(i))
313            .collect()
314    }
315
316    /// Drain all unconsumed faces as FaceRecords.
317    pub fn drain_as_records(&self) -> Vec<FaceRecord> {
318        self.faces
319            .iter()
320            .enumerate()
321            .filter(|(i, _)| !self.is_consumed(*i))
322            .map(|(_, f)| f.to_record())
323            .collect()
324    }
325}
326
327// ============================================================================
328// Edge extraction and matching
329// ============================================================================
330
331/// Extract the 4 boundary edges of a structured face from its parent block.
332pub(crate) fn extract_face_edges(face: &Face, block: &Block) -> Vec<StructuredEdge> {
333    let axis = match face.const_axis() {
334        Some(a) => a,
335        None => return Vec::new(),
336    };
337    let mut edges = Vec::with_capacity(4);
338
339    match axis {
340        FaceAxis::I => {
341            let ic = face.imin();
342            edges.push(build_edge(
343                block,
344                (face.kmin()..=face.kmax())
345                    .map(|k| (ic, face.jmin(), k))
346                    .collect(),
347            ));
348            edges.push(build_edge(
349                block,
350                (face.kmin()..=face.kmax())
351                    .map(|k| (ic, face.jmax(), k))
352                    .collect(),
353            ));
354            edges.push(build_edge(
355                block,
356                (face.jmin()..=face.jmax())
357                    .map(|j| (ic, j, face.kmin()))
358                    .collect(),
359            ));
360            edges.push(build_edge(
361                block,
362                (face.jmin()..=face.jmax())
363                    .map(|j| (ic, j, face.kmax()))
364                    .collect(),
365            ));
366        }
367        FaceAxis::J => {
368            let jc = face.jmin();
369            edges.push(build_edge(
370                block,
371                (face.kmin()..=face.kmax())
372                    .map(|k| (face.imin(), jc, k))
373                    .collect(),
374            ));
375            edges.push(build_edge(
376                block,
377                (face.kmin()..=face.kmax())
378                    .map(|k| (face.imax(), jc, k))
379                    .collect(),
380            ));
381            edges.push(build_edge(
382                block,
383                (face.imin()..=face.imax())
384                    .map(|i| (i, jc, face.kmin()))
385                    .collect(),
386            ));
387            edges.push(build_edge(
388                block,
389                (face.imin()..=face.imax())
390                    .map(|i| (i, jc, face.kmax()))
391                    .collect(),
392            ));
393        }
394        FaceAxis::K => {
395            let kc = face.kmin();
396            edges.push(build_edge(
397                block,
398                (face.jmin()..=face.jmax())
399                    .map(|j| (face.imin(), j, kc))
400                    .collect(),
401            ));
402            edges.push(build_edge(
403                block,
404                (face.jmin()..=face.jmax())
405                    .map(|j| (face.imax(), j, kc))
406                    .collect(),
407            ));
408            edges.push(build_edge(
409                block,
410                (face.imin()..=face.imax())
411                    .map(|i| (i, face.jmin(), kc))
412                    .collect(),
413            ));
414            edges.push(build_edge(
415                block,
416                (face.imin()..=face.imax())
417                    .map(|i| (i, face.jmax(), kc))
418                    .collect(),
419            ));
420        }
421    }
422    edges
423}
424
425fn build_edge(block: &Block, ijk_list: Vec<(usize, usize, usize)>) -> StructuredEdge {
426    let coords: Vec<[Float; 3]> = ijk_list
427        .iter()
428        .map(|&(i, j, k)| {
429            let (x, y, z) = block.xyz(i, j, k);
430            [x, y, z]
431        })
432        .collect();
433    StructuredEdge { coords }
434}
435
436/// Compare two structured edges. Points of edge_a are rotated before comparison.
437/// Returns Full if all points match, Partial if >=2 contiguous match, None otherwise.
438pub(crate) fn match_edges(
439    edge_a: &StructuredEdge,
440    edge_b: &StructuredEdge,
441    rotation_matrix: [[Float; 3]; 3],
442    tol: Float,
443) -> EdgeMatchResult {
444    if edge_a.coords.is_empty() || edge_b.coords.is_empty() {
445        return EdgeMatchResult::None;
446    }
447
448    let rotated_a: Vec<[Float; 3]> = edge_a
449        .coords
450        .iter()
451        .map(|p| apply_rotation(*p, rotation_matrix))
452        .collect();
453
454    // Count how many points from each edge have a match in the other
455    // (order-independent — handles reversed edge traversal)
456    let a_matched = rotated_a
457        .iter()
458        .filter(|ra| edge_b.coords.iter().any(|pb| distance3(**ra, *pb) <= tol))
459        .count();
460    let b_matched = edge_b
461        .coords
462        .iter()
463        .filter(|pb| rotated_a.iter().any(|ra| distance3(*ra, **pb) <= tol))
464        .count();
465
466    let matched = a_matched.min(b_matched);
467
468    if matched < 2 {
469        EdgeMatchResult::None
470    } else if a_matched == rotated_a.len() && b_matched == edge_b.coords.len() {
471        EdgeMatchResult::Full
472    } else {
473        EdgeMatchResult::Partial
474    }
475}
476
477/// Count how many edge pairs between two faces have Full or Partial matches.
478pub(crate) fn count_edge_matches(
479    edges_a: &[StructuredEdge],
480    edges_b: &[StructuredEdge],
481    rotation_matrix: [[Float; 3]; 3],
482    tol: Float,
483) -> usize {
484    let mut count = 0;
485    for ea in edges_a {
486        for eb in edges_b {
487            match match_edges(ea, eb, rotation_matrix, tol) {
488                EdgeMatchResult::None => {}
489                _ => count += 1,
490            }
491        }
492    }
493    count
494}