Skip to main content

outram_foam_basic_lib/mesh/
fv_mesh.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
21
22use crate::mesh::ami::AmiCoupling;
23use crate::mesh::error::MeshError;
24use crate::primitives::Vector3;
25
26/// Boundary patch descriptor: topology + kind.
27///
28/// Face indices in [start, start + size) within the global face array.
29/// All boundary faces appear after the internal faces in OpenFOAM ordering:
30/// `start >= n_internal_faces` for every patch.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BoundaryPatch {
33    /// Patch name (e.g. `"left"`, `"wall"`, `"inlet"`).
34    pub name: String,
35    /// Index of the first face of this patch in the global face list.
36    pub start: usize,
37    /// Number of faces in this patch.
38    pub size: usize,
39    /// Topological type of the patch (wall, symmetry, empty, …).
40    pub kind: PatchKind,
41    /// For a [`PatchKind::Cyclic`] patch, the **patch index** of its matching
42    /// partner (the other half of the periodic pair); `None` for every
43    /// non-cyclic patch, and `None` for a cyclic patch whose partner has not yet
44    /// been resolved (e.g. one read from a `polyMesh` whose `neighbourPatch`
45    /// ordering is not parsed yet).
46    ///
47    /// Mirrors `Foam::cyclicPolyPatch::neighbPatchID()`
48    /// (`src/meshTools/.../cyclic/cyclicPolyPatch.H`). Local face `i` of this
49    /// patch corresponds to local face `i` of the partner patch (OpenFOAM
50    /// half0/half1 ordering), so the two halves must have equal `size`.
51    pub cyclic_partner: Option<usize>,
52}
53
54impl BoundaryPatch {
55    /// Construct a patch spanning faces `[start, start + size)` of the global
56    /// face array, with the given name and [`PatchKind`].
57    ///
58    /// The [`cyclic_partner`](Self::cyclic_partner) is left `None`; for a
59    /// periodic pair use [`new_cyclic`](Self::new_cyclic) instead so the two
60    /// halves know each other.
61    pub fn new(name: impl Into<String>, start: usize, size: usize, kind: PatchKind) -> Self {
62        Self {
63            name: name.into(),
64            start,
65            size,
66            kind,
67            cyclic_partner: None,
68        }
69    }
70
71    /// Construct a [`PatchKind::Cyclic`] (periodic) patch spanning faces
72    /// `[start, start + size)` whose matching partner is patch index
73    /// `partner_patch`.
74    ///
75    /// Local face `i` of this patch is paired with local face `i` of the partner
76    /// (OpenFOAM half0/half1 ordering); the partner patch must have the same
77    /// `size` and must name this patch back as *its* partner. The
78    /// [`FvMeshBuilder`] validates that symmetry and derives the across-seam
79    /// cell couplings ([`CyclicCoupling`]) at build time.
80    ///
81    /// OpenFOAM: `cyclicPolyPatch` (`src/meshTools/.../cyclic/cyclicPolyPatch.H`).
82    pub fn new_cyclic(
83        name: impl Into<String>,
84        start: usize,
85        size: usize,
86        partner_patch: usize,
87    ) -> Self {
88        Self {
89            name: name.into(),
90            start,
91            size,
92            kind: PatchKind::Cyclic,
93            cyclic_partner: Some(partner_patch),
94        }
95    }
96
97    /// Last+1 face index (exclusive upper bound).
98    pub fn end(&self) -> usize {
99        self.start + self.size
100    }
101
102    /// True if global face index `f` belongs to this patch.
103    pub fn contains_face(&self, f: usize) -> bool {
104        f >= self.start && f < self.end()
105    }
106}
107
108/// Topological type of a boundary patch.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum PatchKind {
111    /// Generic boundary patch.
112    Patch,
113    /// No-slip wall.
114    Wall,
115    /// Symmetry plane.
116    Symmetry,
117    /// 2-D reduced case (zero-area faces).
118    Empty,
119    /// Axisymmetric wedge.
120    Wedge,
121    /// Periodic / matching pair (conformal — faces line up one-to-one).
122    Cyclic,
123    /// Non-conformal periodic pair — arbitrary mesh interface (AMI). The two
124    /// halves' faces do not match one-to-one; each target face couples to a
125    /// weighted set of source faces (see [`AmiCoupling`]).
126    /// Mirrors OpenFOAM `cyclicAMIPolyPatch`.
127    CyclicAmi,
128    /// Inter-processor decomposition seam.
129    Processor,
130}
131
132/// One across-the-seam cell coupling introduced by a [`PatchKind::Cyclic`]
133/// (periodic) patch pair.
134///
135/// A cyclic patch pair makes the domain periodic: a boundary face on one half
136/// of the pair is physically the *same* interface as the matching face on the
137/// other half. This struct records, for one such matched face pair, the two
138/// cells it joins so the FV operators can couple them **exactly like an internal
139/// face** — the owner cell of the half0 face (`owner`) is coupled to the owner
140/// cell of the half1 face (`neighbour`), contributing an off-diagonal matrix
141/// entry across the periodic seam.
142///
143/// The couplings are appended to the LDU face addressing *after* the internal
144/// faces (see [`FvMatrix::new`](crate::ldu_matrix::FvMatrix::new)), so coupling
145/// index `i` in [`FvMesh::cyclic_couplings`] occupies LDU face
146/// `n_internal_faces + i`.
147///
148/// Mirrors the coupled-interface contribution of `Foam::cyclicFvPatchField`
149/// (`src/finiteVolume/.../cyclic/cyclicFvPatchField.H`), whose
150/// `patchNeighbourField()` supplies the value from the partner cell.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct CyclicCoupling {
153    /// Owner cell of the half0 (lower-patch-index) face — the "owner" side of
154    /// the coupling.
155    pub owner: usize,
156    /// Owner cell of the matched half1 face — the "neighbour" across the seam.
157    pub neighbour: usize,
158    /// Global face index of the half0 face (on `patch_a`).
159    pub face_a: usize,
160    /// Global face index of the matched half1 face (on `patch_b`).
161    pub face_b: usize,
162    /// Patch index of half0 (the lower of the pair's two indices).
163    pub patch_a: usize,
164    /// Patch index of half1 (the partner of `patch_a`).
165    pub patch_b: usize,
166    /// Local face index within each half (`face_a - patches[patch_a].start ==
167    /// face_b - patches[patch_b].start`).
168    pub local: usize,
169}
170
171/// Finite-volume mesh — topology and geometry in a flat data structure.
172///
173/// Mirrors `Foam::fvMesh` (`src/finiteVolume/fvMesh/fvMesh.H`) but without
174/// the C++ inheritance chain (`polyMesh → primitiveMesh → lduMesh`).
175/// Only the data required by the FV operators is stored.
176///
177/// ## Face ordering (OpenFOAM convention)
178/// ```text
179/// [0 .. n_internal_faces)         ← internal faces (have both owner & neighbour)
180/// [n_internal_faces .. n_faces)   ← boundary faces (owner only)
181/// ```
182/// The `neighbour` array has length `n_internal_faces`; boundary faces have no
183/// entry in `neighbour`.
184///
185/// ## Getting one
186///
187/// This was a documented friction point in an API dogfood run (gh #58): the
188/// prelude exports `FvMesh`, but nothing told the reader *how to obtain one*,
189/// and they ended up reading the test suite to find out. The ready-made
190/// constructors are listed here so that is not necessary:
191///
192/// - [`FvMesh::periodic_1d`] — a 1-D periodic column of `n` cells. The usual
193///   choice for verifying an operator against an analytic solution, since
194///   periodicity removes boundary treatment from the comparison.
195/// - [`FvMesh::periodic_ring_ami`](crate::mesh::ami) — a periodic ring with a
196///   non-conformal AMI interface, for exercising the AMI paths.
197/// - [`FvMeshBuilder`] — build one face by face when neither fits.
198///
199/// Meshes are shared by the field types via `Arc`, so wrap it once:
200///
201/// ```
202/// use outram_foam_basic_lib::prelude::*;
203/// use std::sync::Arc;
204///
205/// // 16 cells over a unit length, unit cross-sectional area.
206/// let mesh = Arc::new(FvMesh::periodic_1d(16, 1.0, 1.0));
207/// assert_eq!(mesh.n_cells, 16);   // a public field, not a method
208///
209/// // A field is then built on that mesh.
210/// let phi = VolScalarField::uniform("phi", mesh.clone(), 0.0);
211/// assert_eq!(phi.internal.len(), 16);
212/// ```
213#[derive(Debug, Clone)]
214pub struct FvMesh {
215    // ── Topology ──────────────────────────────────────────────────────────────
216    /// Number of cells.
217    pub n_cells: usize,
218    /// Number of internal faces (both owner and neighbour defined).
219    pub n_internal_faces: usize,
220    /// Total number of faces (internal + boundary).
221    pub n_faces: usize,
222
223    /// `owner[f]` — cell that owns face `f` (for all faces).
224    pub owner: Vec<usize>,
225    /// `neighbour[f]` — cell on the other side of internal face `f`.
226    /// Length == `n_internal_faces`; boundary faces have no neighbour.
227    pub neighbour: Vec<usize>,
228
229    /// Boundary patch descriptors (one per patch, in face-index order).
230    pub patches: Vec<BoundaryPatch>,
231
232    /// Across-seam cell couplings from [`PatchKind::Cyclic`] (periodic) patch
233    /// pairs, one entry per matched boundary-face pair. Empty for a mesh with no
234    /// (resolved) cyclic pairs. Each entry is treated by the FV operators and
235    /// the LDU matrix exactly like an internal face joining
236    /// [`CyclicCoupling::owner`] and [`CyclicCoupling::neighbour`], appended to
237    /// the LDU face addressing after the `n_internal_faces` internal faces.
238    pub cyclic_couplings: Vec<CyclicCoupling>,
239
240    /// Across-seam couplings from [`PatchKind::CyclicAmi`] (non-conformal
241    /// periodic) patch pairs, one entry per **target** seam face. Empty for a
242    /// mesh with no AMI pairs. Each entry couples its target cell to a weighted
243    /// set of source cells (the geometric face overlaps); the FV operators and
244    /// the LDU matrix append one LDU face per [`AmiWeight`](crate::mesh::AmiWeight)
245    /// after the internal faces and the [`cyclic_couplings`](Self::cyclic_couplings)
246    /// (see [`ami_ldu_start`](Self::ami_ldu_start)). Mirrors OpenFOAM
247    /// `cyclicAMIFvPatchField`.
248    pub ami_couplings: Vec<AmiCoupling>,
249
250    // ── Geometry ──────────────────────────────────────────────────────────────
251    /// Cell volumes `V[c]` `[m³]`.
252    pub cell_volumes: Vec<f64>,
253    /// Cell centres `C[c]` `[m]`.
254    pub cell_centres: Vec<Vector3>,
255    /// Face area vectors `Sf[f]` `[m²]`, pointing from owner toward neighbour
256    /// (or outward for boundary faces).
257    pub face_area_vectors: Vec<Vector3>,
258    /// Face area magnitudes `|Sf[f]|` `[m²]`.
259    pub face_areas: Vec<f64>,
260    /// Face centres `Cf[f]` `[m]`.
261    pub face_centres: Vec<Vector3>,
262}
263
264impl FvMesh {
265    /// Total number of boundary faces.
266    pub fn n_boundary_faces(&self) -> usize {
267        self.n_faces - self.n_internal_faces
268    }
269
270    /// Number of patches.
271    pub fn n_patches(&self) -> usize {
272        self.patches.len()
273    }
274
275    /// True if face `f` is an internal face (has a neighbour cell).
276    pub fn is_internal_face(&self, f: usize) -> bool {
277        f < self.n_internal_faces
278    }
279
280    /// Given a global face index `f` that is a boundary face, return
281    /// `(patch_index, local_face_index_within_patch)`.
282    /// Returns `None` if `f` is an internal face.
283    pub fn patch_for_face(&self, f: usize) -> Option<(usize, usize)> {
284        if self.is_internal_face(f) {
285            return None;
286        }
287        for (pi, patch) in self.patches.iter().enumerate() {
288            if patch.contains_face(f) {
289                return Some((pi, f - patch.start));
290            }
291        }
292        None
293    }
294
295    /// Number of across-seam cyclic couplings (length of
296    /// [`cyclic_couplings`](Self::cyclic_couplings)).
297    pub fn n_cyclic_couplings(&self) -> usize {
298        self.cyclic_couplings.len()
299    }
300
301    /// LDU face index of cyclic coupling `i`.
302    ///
303    /// Cyclic couplings live *after* the internal faces in the LDU addressing,
304    /// so coupling `i` occupies face `n_internal_faces + i`. The FV operators
305    /// use this to write the off-diagonal coefficient of a periodic seam into
306    /// the matrix.
307    pub fn cyclic_coupling_face(&self, i: usize) -> usize {
308        self.n_internal_faces + i
309    }
310
311    /// Given a global boundary face index on a resolved [`PatchKind::Cyclic`]
312    /// patch, return the global face index of its matching partner face across
313    /// the periodic seam.
314    ///
315    /// Returns `None` for internal faces, for non-cyclic boundary faces, and for
316    /// cyclic faces whose partner patch is unresolved. Local face `i` of a
317    /// cyclic patch maps to local face `i` of its partner (OpenFOAM half0/half1
318    /// ordering).
319    pub fn cyclic_partner_face(&self, global_face: usize) -> Option<usize> {
320        let (pi, local) = self.patch_for_face(global_face)?;
321        let patch = &self.patches[pi];
322        if patch.kind != PatchKind::Cyclic {
323            return None;
324        }
325        let pj = patch.cyclic_partner?;
326        let partner = self.patches.get(pj)?;
327        if local >= partner.size {
328            return None;
329        }
330        Some(partner.start + local)
331    }
332
333    /// Build a uniform 1-D **periodic** (cyclic) mesh: `n` equal cells along the
334    /// x-axis over `[0, length]`, with the two ends joined by a cyclic patch
335    /// pair so the domain wraps around.
336    ///
337    /// This is the programmatic constructor for a periodic seam (the first-pass
338    /// path — the `polyMesh` parser does not yet read cyclic `neighbourPatch`
339    /// ordering). The resulting mesh has:
340    ///
341    /// - `n` cells of width `h = length / n`, centres at `(i + 0.5)·h`, volume
342    ///   `h·area` `[m³]`;
343    /// - `n − 1` internal faces (area vector `+x·area` `[m²]`);
344    /// - two 1-face cyclic patches — `"left"` at `x = 0` (patch 0, outward
345    ///   normal `−x`) and `"right"` at `x = length` (patch 1, outward normal
346    ///   `+x`) — that are each other's partners.
347    ///
348    /// The derived [`CyclicCoupling`] joins cell `0` (left owner) to cell `n − 1`
349    /// (right owner) with the same cell-to-cell distance `h` as an interior
350    /// face, so a periodic problem discretised on this mesh reproduces the
351    /// equivalent all-internal ring mesh (see the crate's cyclic V&V tests).
352    ///
353    /// # Parameters
354    /// - `n` — number of cells (must be ≥ 2 for a meaningful periodic loop);
355    /// - `length` — domain length `[m]`;
356    /// - `area` — uniform cross-sectional face area `[m²]`.
357    ///
358    /// # Panics
359    /// Panics if `n < 2` (a periodic loop needs at least two cells) or if
360    /// `length`/`area` are not positive.
361    ///
362    /// OpenFOAM analogue: a `blockMesh` block with a `cyclic` patch pair on its
363    /// two x-normal faces.
364    pub fn periodic_1d(n: usize, length: f64, area: f64) -> FvMesh {
365        assert!(n >= 2, "periodic_1d needs at least 2 cells, got {n}");
366        assert!(length > 0.0, "length must be positive, got {length}");
367        assert!(area > 0.0, "area must be positive, got {area}");
368        let h = length / n as f64;
369        let n_int = n - 1;
370
371        // owner: internal faces couple cell f ↔ cell f+1; then the two boundary
372        // faces (left owner = cell 0, right owner = cell n-1).
373        let mut owner: Vec<usize> = (0..n_int).collect();
374        owner.push(0); // left boundary face (patch 0)
375        owner.push(n - 1); // right boundary face (patch 1)
376        let neighbour: Vec<usize> = (1..n).collect();
377
378        let cell_volumes = vec![h * area; n];
379        let cell_centres = (0..n)
380            .map(|i| Vector3::new((i as f64 + 0.5) * h, 0.0, 0.0))
381            .collect();
382
383        // Internal face centres at (f+1)*h; then left face at x=0, right at
384        // x=length.
385        let face_centres: Vec<Vector3> = (0..n_int)
386            .map(|f| Vector3::new((f as f64 + 1.0) * h, 0.0, 0.0))
387            .chain([Vector3::new(0.0, 0.0, 0.0), Vector3::new(length, 0.0, 0.0)])
388            .collect();
389        // Internal Sf = +x·area (owner→neighbour); left boundary outward = −x,
390        // right boundary outward = +x.
391        let face_area_vectors: Vec<Vector3> = (0..n_int)
392            .map(|_| Vector3::new(area, 0.0, 0.0))
393            .chain([Vector3::new(-area, 0.0, 0.0), Vector3::new(area, 0.0, 0.0)])
394            .collect();
395
396        FvMeshBuilder::new()
397            .n_cells(n)
398            .n_internal_faces(n_int)
399            .owner(owner)
400            .neighbour(neighbour)
401            .patches(vec![
402                BoundaryPatch::new_cyclic("left", n_int, 1, 1),
403                BoundaryPatch::new_cyclic("right", n_int + 1, 1, 0),
404            ])
405            .cell_volumes(cell_volumes)
406            .cell_centres(cell_centres)
407            .face_area_vectors(face_area_vectors)
408            .face_centres(face_centres)
409            .build()
410            .expect("periodic_1d builds a consistent mesh")
411    }
412
413    /// Validate basic mesh consistency.  Returns `Err` on the first problem found.
414    pub fn validate(&self) -> Result<(), MeshError> {
415        let check_len = |array, got, expected| {
416            if got != expected {
417                Err(MeshError::ArrayLengthMismatch {
418                    array,
419                    expected,
420                    got,
421                })
422            } else {
423                Ok(())
424            }
425        };
426        check_len("owner", self.owner.len(), self.n_faces)?;
427        check_len("neighbour", self.neighbour.len(), self.n_internal_faces)?;
428        check_len("cell_volumes", self.cell_volumes.len(), self.n_cells)?;
429        check_len("cell_centres", self.cell_centres.len(), self.n_cells)?;
430        check_len(
431            "face_area_vectors",
432            self.face_area_vectors.len(),
433            self.n_faces,
434        )?;
435        check_len("face_areas", self.face_areas.len(), self.n_faces)?;
436        check_len("face_centres", self.face_centres.len(), self.n_faces)?;
437
438        // Patches must cover [n_internal_faces, n_faces) without gaps.
439        let mut covered = self.n_internal_faces;
440        for p in &self.patches {
441            if p.start != covered {
442                return Err(MeshError::PatchStartMismatch {
443                    name: p.name.clone(),
444                    expected: covered,
445                    got: p.start,
446                });
447            }
448            covered += p.size;
449        }
450        if covered != self.n_faces {
451            return Err(MeshError::PatchCoverageMismatch {
452                covered,
453                n_faces: self.n_faces,
454            });
455        }
456
457        // Cyclic patch pairs must be mutually symmetric and equally sized.
458        for (pi, patch) in self.patches.iter().enumerate() {
459            let Some(pj) = patch.cyclic_partner else {
460                continue;
461            };
462            let partner = self.patches.get(pj).ok_or(MeshError::CyclicPairMismatch {
463                name: patch.name.clone(),
464                reason: "partner patch index out of range",
465            })?;
466            if partner.cyclic_partner != Some(pi) {
467                return Err(MeshError::CyclicPairMismatch {
468                    name: patch.name.clone(),
469                    reason: "partner patch does not name this patch back",
470                });
471            }
472            if partner.size != patch.size {
473                return Err(MeshError::CyclicPairMismatch {
474                    name: patch.name.clone(),
475                    reason: "cyclic pair halves have different face counts",
476                });
477            }
478        }
479
480        // AMI couplings: every target/source cell index must be in range, and
481        // every target must have at least one weighted source.
482        for cc in &self.ami_couplings {
483            if cc.target_cell >= self.n_cells || cc.target_face >= self.n_faces {
484                return Err(MeshError::AmiCouplingInvalid {
485                    target_face: cc.target_face,
486                    reason: "target cell/face index out of range",
487                });
488            }
489            if cc.weights.is_empty() {
490                return Err(MeshError::AmiCouplingInvalid {
491                    target_face: cc.target_face,
492                    reason: "AMI target face has no overlapping source faces",
493                });
494            }
495            for w in &cc.weights {
496                if w.source_cell >= self.n_cells || w.source_face >= self.n_faces {
497                    return Err(MeshError::AmiCouplingInvalid {
498                        target_face: cc.target_face,
499                        reason: "source cell/face index out of range",
500                    });
501                }
502            }
503        }
504        Ok(())
505    }
506}
507
508/// Builder for `FvMesh` — lets tests and I/O code assemble a mesh incrementally.
509#[derive(Debug, Clone, Default)]
510pub struct FvMeshBuilder {
511    n_cells: usize,
512    n_internal_faces: usize,
513    owner: Vec<usize>,
514    neighbour: Vec<usize>,
515    patches: Vec<BoundaryPatch>,
516    ami_couplings: Vec<AmiCoupling>,
517    cell_volumes: Vec<f64>,
518    cell_centres: Vec<Vector3>,
519    face_area_vectors: Vec<Vector3>,
520    face_areas: Vec<f64>,
521    face_centres: Vec<Vector3>,
522}
523
524impl FvMeshBuilder {
525    /// New empty builder (all arrays empty, all counts zero).
526    pub fn new() -> Self {
527        Self::default()
528    }
529
530    /// Set the number of cells.
531    pub fn n_cells(mut self, n: usize) -> Self {
532        self.n_cells = n;
533        self
534    }
535    /// Set the number of internal faces (faces with both owner and neighbour).
536    pub fn n_internal_faces(mut self, n: usize) -> Self {
537        self.n_internal_faces = n;
538        self
539    }
540    /// Set the `owner` array (owning cell per face; length == `n_faces`).
541    pub fn owner(mut self, v: Vec<usize>) -> Self {
542        self.owner = v;
543        self
544    }
545    /// Set the `neighbour` array (neighbour cell per internal face; length ==
546    /// `n_internal_faces`).
547    pub fn neighbour(mut self, v: Vec<usize>) -> Self {
548        self.neighbour = v;
549        self
550    }
551    /// Set the boundary patch descriptors.
552    pub fn patches(mut self, v: Vec<BoundaryPatch>) -> Self {
553        self.patches = v;
554        self
555    }
556    /// Set the non-conformal-periodic (AMI) seam couplings (one entry per target
557    /// seam face). Left empty by default; set by AMI mesh constructors such as
558    /// [`FvMesh::periodic_ring_ami`].
559    pub fn ami_couplings(mut self, v: Vec<AmiCoupling>) -> Self {
560        self.ami_couplings = v;
561        self
562    }
563    /// Set the cell volumes `V[c]` `[m³]` (length == `n_cells`).
564    pub fn cell_volumes(mut self, v: Vec<f64>) -> Self {
565        self.cell_volumes = v;
566        self
567    }
568    /// Set the cell centres `C[c]` `[m]` (length == `n_cells`).
569    pub fn cell_centres(mut self, v: Vec<Vector3>) -> Self {
570        self.cell_centres = v;
571        self
572    }
573    /// Set the face area vectors `Sf[f]` `[m²]` (length == `n_faces`).
574    pub fn face_area_vectors(mut self, v: Vec<Vector3>) -> Self {
575        self.face_area_vectors = v;
576        self
577    }
578    /// Set the face area magnitudes `|Sf[f]|` `[m²]`. If left unset, they are
579    /// derived from `face_area_vectors` at build time.
580    pub fn face_areas(mut self, v: Vec<f64>) -> Self {
581        self.face_areas = v;
582        self
583    }
584    /// Set the face centres `Cf[f]` `[m]` (length == `n_faces`).
585    pub fn face_centres(mut self, v: Vec<Vector3>) -> Self {
586        self.face_centres = v;
587        self
588    }
589
590    /// Derive `face_areas` from `face_area_vectors` if not set.
591    fn ensure_face_areas(&mut self) {
592        if self.face_areas.is_empty() && !self.face_area_vectors.is_empty() {
593            self.face_areas = self.face_area_vectors.iter().map(|sf| sf.mag()).collect();
594        }
595    }
596
597    /// Finalise the mesh: derive `face_areas` if needed, resolve any cyclic
598    /// (periodic) patch pairs into [`CyclicCoupling`]s, assemble the [`FvMesh`],
599    /// and run [`FvMesh::validate`]. Returns `Err` on the first consistency
600    /// problem found.
601    ///
602    /// Cyclic couplings are derived from every [`PatchKind::Cyclic`] patch whose
603    /// [`BoundaryPatch::cyclic_partner`] is set: each matched local face pair
604    /// (half0 local `i` ↔ half1 local `i`) becomes one coupling joining the two
605    /// faces' owner cells. Each pair is processed once (from the lower patch
606    /// index).
607    pub fn build(mut self) -> Result<FvMesh, MeshError> {
608        self.ensure_face_areas();
609        let n_faces = self.owner.len();
610        let cyclic_couplings = Self::derive_cyclic_couplings(&self.patches, &self.owner);
611        let mesh = FvMesh {
612            n_cells: self.n_cells,
613            n_internal_faces: self.n_internal_faces,
614            n_faces,
615            owner: self.owner,
616            neighbour: self.neighbour,
617            patches: self.patches,
618            cyclic_couplings,
619            ami_couplings: self.ami_couplings,
620            cell_volumes: self.cell_volumes,
621            cell_centres: self.cell_centres,
622            face_area_vectors: self.face_area_vectors,
623            face_areas: self.face_areas,
624            face_centres: self.face_centres,
625        };
626        mesh.validate()?;
627        Ok(mesh)
628    }
629
630    /// Build the across-seam couplings for every resolved cyclic patch pair.
631    ///
632    /// A pair `(pi, pj)` with `pi < pj` yields one [`CyclicCoupling`] per local
633    /// face: `owner = owner[patches[pi].start + i]`, `neighbour =
634    /// owner[patches[pj].start + i]`. Malformed pairs (missing/asymmetric
635    /// partner, mismatched size) are skipped here and caught by
636    /// [`FvMesh::validate`], which returns a descriptive error.
637    fn derive_cyclic_couplings(patches: &[BoundaryPatch], owner: &[usize]) -> Vec<CyclicCoupling> {
638        let mut couplings = Vec::new();
639        for (pi, patch) in patches.iter().enumerate() {
640            if patch.kind != PatchKind::Cyclic {
641                continue;
642            }
643            let Some(pj) = patch.cyclic_partner else {
644                continue;
645            };
646            // Process each pair once, from the lower index; skip malformed pairs
647            // (validate() reports them).
648            if pj <= pi {
649                continue;
650            }
651            let Some(partner) = patches.get(pj) else {
652                continue;
653            };
654            if partner.cyclic_partner != Some(pi) || partner.size != patch.size {
655                continue;
656            }
657            for local in 0..patch.size {
658                let face_a = patch.start + local;
659                let face_b = partner.start + local;
660                couplings.push(CyclicCoupling {
661                    owner: owner[face_a],
662                    neighbour: owner[face_b],
663                    face_a,
664                    face_b,
665                    patch_a: pi,
666                    patch_b: pj,
667                    local,
668                });
669            }
670        }
671        couplings
672    }
673}
674
675// ── Tests ─────────────────────────────────────────────────────────────────────
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    /// Simple 1-D mesh: two cells sharing one internal face and each having
682    /// one boundary face.
683    ///
684    /// ```
685    ///  |   cell 0   |   cell 1   |
686    ///  ^            ^            ^
687    /// face 2    face 0(int)    face 1
688    /// (patch 0)               (patch 1)
689    /// ```
690    fn two_cell_mesh() -> FvMesh {
691        FvMeshBuilder::new()
692            .n_cells(2)
693            .n_internal_faces(1)
694            // faces: [0=internal, 1=right boundary, 2=left boundary]
695            .owner(vec![0, 1, 0])
696            .neighbour(vec![1]) // only internal face 0
697            .patches(vec![
698                BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
699                BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
700            ])
701            .cell_volumes(vec![1.0, 1.0])
702            .cell_centres(vec![
703                Vector3::new(0.25, 0.0, 0.0),
704                Vector3::new(0.75, 0.0, 0.0),
705            ])
706            .face_area_vectors(vec![
707                Vector3::new(1.0, 0.0, 0.0), // internal face: pointing right (owner→neighbour)
708                Vector3::new(1.0, 0.0, 0.0), // right boundary: outward
709                Vector3::new(-1.0, 0.0, 0.0), // left boundary: outward
710            ])
711            .face_centres(vec![
712                Vector3::new(0.5, 0.0, 0.0),
713                Vector3::new(1.0, 0.0, 0.0),
714                Vector3::new(0.0, 0.0, 0.0),
715            ])
716            .build()
717            .expect("two_cell_mesh should be valid")
718    }
719
720    #[test]
721    fn validate_two_cell_mesh() {
722        let m = two_cell_mesh();
723        assert_eq!(m.n_cells, 2);
724        assert_eq!(m.n_internal_faces, 1);
725        assert_eq!(m.n_faces, 3);
726        assert_eq!(m.n_boundary_faces(), 2);
727    }
728
729    #[test]
730    fn is_internal_face() {
731        let m = two_cell_mesh();
732        assert!(m.is_internal_face(0));
733        assert!(!m.is_internal_face(1));
734        assert!(!m.is_internal_face(2));
735    }
736
737    #[test]
738    fn patch_for_face() {
739        let m = two_cell_mesh();
740        assert_eq!(m.patch_for_face(0), None); // internal
741        assert_eq!(m.patch_for_face(1), Some((0, 0))); // right patch (index 0), local face 0
742        assert_eq!(m.patch_for_face(2), Some((1, 0))); // left patch (index 1), local face 0
743    }
744
745    #[test]
746    fn face_areas_derived() {
747        let m = two_cell_mesh();
748        assert!((m.face_areas[0] - 1.0).abs() < 1e-15);
749    }
750
751    #[test]
752    fn periodic_1d_topology() {
753        // 4-cell periodic ring, length 1, area 1.
754        let m = FvMesh::periodic_1d(4, 1.0, 1.0);
755        assert_eq!(m.n_cells, 4);
756        assert_eq!(m.n_internal_faces, 3);
757        assert_eq!(m.n_faces, 5); // 3 internal + 2 boundary
758        assert_eq!(m.patches.len(), 2);
759        assert_eq!(m.patches[0].kind, PatchKind::Cyclic);
760        assert_eq!(m.patches[0].cyclic_partner, Some(1));
761        assert_eq!(m.patches[1].cyclic_partner, Some(0));
762        // One cyclic coupling: cell 0 (left owner) ↔ cell 3 (right owner).
763        assert_eq!(m.n_cyclic_couplings(), 1);
764        let cc = m.cyclic_couplings[0];
765        assert_eq!(cc.owner, 0);
766        assert_eq!(cc.neighbour, 3);
767        assert_eq!(cc.patch_a, 0);
768        assert_eq!(cc.patch_b, 1);
769        // LDU coupling face sits right after the internal faces.
770        assert_eq!(m.cyclic_coupling_face(0), 3);
771    }
772
773    #[test]
774    fn cyclic_partner_face_maps_across_seam() {
775        let m = FvMesh::periodic_1d(4, 1.0, 1.0);
776        // Left boundary face is global index 3; its partner is the right face 4.
777        assert_eq!(m.cyclic_partner_face(3), Some(4));
778        assert_eq!(m.cyclic_partner_face(4), Some(3));
779        // Internal faces have no cyclic partner.
780        assert_eq!(m.cyclic_partner_face(0), None);
781    }
782
783    #[test]
784    fn cyclic_pair_validation_rejects_asymmetric_partner() {
785        // Patch 0 claims patch 1 as partner, but patch 1 is a plain wall.
786        let err = FvMeshBuilder::new()
787            .n_cells(2)
788            .n_internal_faces(1)
789            .owner(vec![0, 0, 1])
790            .neighbour(vec![1])
791            .patches(vec![
792                BoundaryPatch::new_cyclic("left", 1, 1, 1),
793                BoundaryPatch::new("right", 2, 1, PatchKind::Wall),
794            ])
795            .cell_volumes(vec![1.0, 1.0])
796            .cell_centres(vec![
797                Vector3::new(0.25, 0.0, 0.0),
798                Vector3::new(0.75, 0.0, 0.0),
799            ])
800            .face_area_vectors(vec![
801                Vector3::new(1.0, 0.0, 0.0),
802                Vector3::new(-1.0, 0.0, 0.0),
803                Vector3::new(1.0, 0.0, 0.0),
804            ])
805            .face_centres(vec![
806                Vector3::new(0.5, 0.0, 0.0),
807                Vector3::new(0.0, 0.0, 0.0),
808                Vector3::new(1.0, 0.0, 0.0),
809            ])
810            .build();
811        assert!(matches!(err, Err(MeshError::CyclicPairMismatch { .. })));
812    }
813}