//! A structured curvilinear 2-D patch: the mesh a body-fitted overset //! patch lives on (`docs/overset_metal_campaign.md` §2.1, A-P0). //! //! Cells are indexed `(k, i)` with `i` along the body (`s`, possibly //! periodic — an O-grid) and `k` across it (`n`, from the wall outward). //! Nodes are stored as `(nn + 1) × (ns + 1)` coordinates even when the //! patch is periodic: column `ns` is then a copy of column `0` (bitwise //! when the periodic shift is zero), so every cell reads its four corners //! from its own columns and the seam is never a special case. //! //! The `(s, n)` frame must be right-handed (every cell's corner loop //! `(k,i) (k,i+1) (k+1,i+1) (k+1,i)` counter-clockwise, positive area): for //! an O-grid with `n` pointing away from the body that means `s` runs //! CLOCKWISE around it — walk the outline with the body on your right. //! //! Every face carries its geometry once: the area vector `S_f` (length × //! unit normal, oriented toward +s for s-faces and +n for n-faces), the //! owner (−side) and neighbour (+side) cells, the centre-to-centre vector //! `d_f` with the periodic shift already applied, and the owner's linear //! interpolation weight. Boundary faces have one of owner/neighbour //! missing and `d_f` spanning cell centre ↔ face centre, still oriented +. //! //! The metrics are what the collocated solver consumes; the solver never //! touches node coordinates. Neighbour access goes through the face //! lists, so the seam and the boundaries are handled here, once. use crate::error::{CfdError, CfdResult}; /// One face of the patch with its geometry. #[derive(Debug, Clone, Copy)] pub struct Face { /// End nodes (flat node indices), ordered so that `S_f` is the /// +90° rotation of `n0 → n1` for s-faces and the −90° rotation for /// n-faces; the solver's tangential derivative runs `n0 → n1`. pub n0: usize, /// See `n0`. pub n1: usize, /// Face centre (midpoint of the two nodes). pub centre: [f64; 2], /// Area vector `S_f`: length × unit normal, oriented +s or +n. pub s: [f64; 2], /// Cell on the − side of the face (`None` on a − boundary). pub owner: Option, /// Cell on the + side of the face (`None` on a + boundary). pub neigh: Option, /// Owner centre → neighbour centre (periodic shift applied), or cell /// centre ↔ face centre on a boundary; always oriented +. pub d: [f64; 2], /// Owner weight for linear interpolation: `φ_f = w φ_P + (1 − w) φ_N`, /// distance-weighted. `1` on boundary faces (the interior cell). pub w: f64, } /// Which boundary of the patch a boundary face lies on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Side { /// `k = 0`: the body side (the wall of an O-grid). Inner, /// `k = nn`: the far side (the overlap ring of an O-grid). Outer, /// `i = 0` (non-periodic only). SStart, /// `i = ns` (non-periodic only). SEnd, } /// A structured curvilinear patch with precomputed metrics. #[derive(Debug, Clone)] pub struct PatchMesh { ns: usize, nn: usize, periodic: Option<[f64; 2]>, x: Vec, y: Vec, centre: Vec<[f64; 2]>, area: Vec, faces: Vec, n_sfaces: usize, } impl PatchMesh { /// Build a patch from node coordinates laid out `(nn + 1)` rows of /// `(ns + 1)` columns, flat index `k * (ns + 1) + i`. /// /// `periodic = Some(shift)` closes the s direction: column `ns` must /// equal column `0 + shift` (to 1e-12 relative; when `shift` is zero /// it is overwritten with a bitwise copy so the seam face metrics /// agree exactly). Every cell must have positive area. pub fn from_nodes( ns: usize, nn: usize, mut x: Vec, mut y: Vec, periodic: Option<[f64; 2]>, ) -> CfdResult { let cols = ns + 1; if ns < 4 || nn < 2 { return Err(CfdError::mesh(format!( "patch needs ns >= 4 and nn >= 2, got ns = {ns}, nn = {nn}" ))); } if x.len() != (nn + 1) * cols || y.len() != x.len() { return Err(CfdError::mesh(format!( "patch nodes: expected {} coordinates, got {} / {}", (nn + 1) * cols, x.len(), y.len() ))); } if let Some(shift) = periodic { let scale = 1e-12 * x.iter() .chain(y.iter()) .fold(0.0_f64, |m, v| m.max(v.abs())) .max(1.0); for k in 0..=nn { let (a, b) = (k * cols, k * cols + ns); let (ex, ey) = (x[a] + shift[0], y[a] + shift[1]); if (x[b] - ex).abs() > scale || (y[b] - ey).abs() > scale { return Err(CfdError::mesh(format!( "periodic patch: node column {ns} != column 0 + shift at row {k}" ))); } if shift == [0.0, 0.0] { x[b] = x[a]; y[b] = y[a]; } } } let mut mesh = Self { ns, nn, periodic, x, y, centre: Vec::new(), area: Vec::new(), faces: Vec::new(), n_sfaces: 0, }; mesh.build_cells()?; mesh.build_faces(); Ok(mesh) } /// Cells along the body. pub fn ns(&self) -> usize { self.ns } /// Cells across the patch. pub fn nn(&self) -> usize { self.nn } /// Number of cells. pub fn cell_count(&self) -> usize { self.ns * self.nn } /// Whether the s direction is closed, and its translation. pub fn periodic(&self) -> Option<[f64; 2]> { self.periodic } /// Flat cell index of `(k, i)`. pub fn cell(&self, k: usize, i: usize) -> usize { k * self.ns + i } /// `(k, i)` of a flat cell index. pub fn cell_ki(&self, c: usize) -> (usize, usize) { (c / self.ns, c % self.ns) } /// Flat node index of row `k`, column `i`. pub fn node(&self, k: usize, i: usize) -> usize { k * (self.ns + 1) + i } /// Node coordinates. /// `[x_min, x_max, y_min, y_max]` over the nodes. pub fn bounding_box(&self) -> [f64; 4] { let (mut b, mut first) = ([0.0; 4], true); for (&x, &y) in self.x.iter().zip(&self.y) { if first { b = [x, x, y, y]; first = false; } else { b[0] = b[0].min(x); b[1] = b[1].max(x); b[2] = b[2].min(y); b[3] = b[3].max(y); } } b } pub fn node_xy(&self, n: usize) -> [f64; 2] { [self.x[n], self.y[n]] } /// Cell centroid. pub fn centre(&self, c: usize) -> [f64; 2] { self.centre[c] } /// Cell area. pub fn area(&self, c: usize) -> f64 { self.area[c] } /// All faces: s-faces first, then n-faces. pub fn faces(&self) -> &[Face] { &self.faces } /// Number of s-faces per row. pub fn sfaces_per_row(&self) -> usize { if self.periodic.is_some() { self.ns } else { self.ns + 1 } } /// Face index of the s-face at node column `i` in cell row `k` /// (`i` in `0..sfaces_per_row()`; for a periodic patch column 0 is the /// seam, adjacent to cells `ns − 1` and `0`). pub fn sface(&self, k: usize, i: usize) -> usize { k * self.sfaces_per_row() + i } /// Face index of the n-face at node row `k` in cell column `i` /// (`k` in `0..=nn`). pub fn nface(&self, k: usize, i: usize) -> usize { self.n_sfaces + k * self.ns + i } /// Whether face `f` is an s-face. pub fn is_sface(&self, f: usize) -> bool { f < self.n_sfaces } /// The four faces of a cell with the sign that makes `sign · S_f` the /// outward area vector: `[west, east, south, north]`. pub fn cell_faces(&self, c: usize) -> [(usize, f64); 4] { let (k, i) = self.cell_ki(c); let east = if self.periodic.is_some() { (i + 1) % self.ns } else { i + 1 }; [ (self.sface(k, i), -1.0), (self.sface(k, east), 1.0), (self.nface(k, i), -1.0), (self.nface(k + 1, i), 1.0), ] } /// Which boundary a face lies on, if any. pub fn side(&self, f: usize) -> Option { let face = &self.faces[f]; match (face.owner, face.neigh) { (Some(_), Some(_)) => None, (None, Some(_)) => Some(if self.is_sface(f) { Side::SStart } else { Side::Inner }), (Some(_), None) => Some(if self.is_sface(f) { Side::SEnd } else { Side::Outer }), (None, None) => unreachable!("a face without cells"), } } /// The cells touching node `(k, i)` (2 to 4 of them; the seam node is /// resolved by wrapping when periodic). pub fn node_cells(&self, k: usize, i: usize) -> Vec { let mut out = Vec::with_capacity(4); let rows = [k.checked_sub(1), (k < self.nn).then_some(k)]; let cols: [Option; 2] = if self.periodic.is_some() { let i = i % self.ns; [Some((i + self.ns - 1) % self.ns), Some(i)] } else { [i.checked_sub(1), (i < self.ns).then_some(i)] }; for r in rows.into_iter().flatten() { for c in cols.into_iter().flatten() { out.push(self.cell(r, c)); } } out } /// The interior cell of a boundary face. pub fn boundary_cell(&self, f: usize) -> usize { let face = &self.faces[f]; face.owner.or(face.neigh).expect("a face without cells") } /// Mesh quality check: positive areas (built in), interior /// non-orthogonality angle below `max_angle_deg`, and no collapsed /// faces. The overset's later shapes (the cylinder–flag junction) must /// fail here loudly rather than produce a NaN in the solver. pub fn validate(&self, max_angle_deg: f64) -> Result<(), String> { let cos_min = max_angle_deg.to_radians().cos(); for (f, face) in self.faces.iter().enumerate() { let len = (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt(); let dl = (face.d[0] * face.d[0] + face.d[1] * face.d[1]).sqrt(); if len == 0.0 || dl == 0.0 { return Err(format!( "face {f} is collapsed (|S| = {len:.3e}, |d| = {dl:.3e})" )); } if face.owner.is_some() && face.neigh.is_some() { let cos = (face.s[0] * face.d[0] + face.s[1] * face.d[1]) / (len * dl); if cos < cos_min { return Err(format!( "face {f}: non-orthogonality {:.1}° exceeds {max_angle_deg}°", cos.clamp(-1.0, 1.0).acos().to_degrees() )); } } } Ok(()) } fn build_cells(&mut self) -> CfdResult<()> { let (ns, nn) = (self.ns, self.nn); self.centre = vec![[0.0; 2]; ns * nn]; self.area = vec![0.0; ns * nn]; for k in 0..nn { for i in 0..ns { // Corners counter-clockwise: (k,i) (k,i+1) (k+1,i+1) (k+1,i). let n = [ self.node(k, i), self.node(k, i + 1), self.node(k + 1, i + 1), self.node(k + 1, i), ]; let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0); for q in 0..4 { let (p0, p1) = (n[q], n[(q + 1) % 4]); let cross = self.x[p0] * self.y[p1] - self.x[p1] * self.y[p0]; a2 += cross; cx += (self.x[p0] + self.x[p1]) * cross; cy += (self.y[p0] + self.y[p1]) * cross; } if a2 <= 0.0 { return Err(CfdError::mesh(format!( "patch cell ({k}, {i}) has non-positive area {:.3e}", 0.5 * a2 ))); } let c = self.cell(k, i); self.area[c] = 0.5 * a2; self.centre[c] = [cx / (3.0 * a2), cy / (3.0 * a2)]; } } Ok(()) } fn build_faces(&mut self) { let (ns, nn) = (self.ns, self.nn); let per_row = self.sfaces_per_row(); let mut faces = Vec::with_capacity(nn * per_row + (nn + 1) * ns); // s-faces: at node column i, between cells (k, i-1) and (k, i). for k in 0..nn { for i in 0..per_row { let (owner, neigh, col) = if self.periodic.is_some() { if i == 0 { // The seam: geometry from column ns (owner's side), // the neighbour (cell 0) sits one period ahead. (Some(self.cell(k, ns - 1)), Some(self.cell(k, 0)), ns) } else { (Some(self.cell(k, i - 1)), Some(self.cell(k, i)), i) } } else { ( (i > 0).then(|| self.cell(k, i - 1)), (i < ns).then(|| self.cell(k, i)), i, ) }; let (n0, n1) = (self.node(k, col), self.node(k + 1, col)); let t = [self.x[n1] - self.x[n0], self.y[n1] - self.y[n0]]; let s = [t[1], -t[0]]; let seam_shift = if self.periodic.is_some() && i == 0 { self.periodic.unwrap_or([0.0; 2]) } else { [0.0; 2] }; faces.push(self.make_face(n0, n1, s, owner, neigh, seam_shift)); } } self.n_sfaces = faces.len(); // n-faces: at node row k, between cells (k-1, i) and (k, i). for k in 0..=nn { for i in 0..ns { let owner = (k > 0).then(|| self.cell(k - 1, i)); let neigh = (k < nn).then(|| self.cell(k, i)); let (n0, n1) = (self.node(k, i), self.node(k, i + 1)); let t = [self.x[n1] - self.x[n0], self.y[n1] - self.y[n0]]; let s = [-t[1], t[0]]; faces.push(self.make_face(n0, n1, s, owner, neigh, [0.0; 2])); } } self.faces = faces; } fn make_face( &self, n0: usize, n1: usize, s: [f64; 2], owner: Option, neigh: Option, shift: [f64; 2], ) -> Face { let centre = [ 0.5 * (self.x[n0] + self.x[n1]), 0.5 * (self.y[n0] + self.y[n1]), ]; let (d, w) = match (owner, neigh) { (Some(p), Some(q)) => { let cp = self.centre[p]; let cq = [self.centre[q][0] + shift[0], self.centre[q][1] + shift[1]]; let dp = ((centre[0] - cp[0]).powi(2) + (centre[1] - cp[1]).powi(2)).sqrt(); let dq = ((cq[0] - centre[0]).powi(2) + (cq[1] - centre[1]).powi(2)).sqrt(); ([cq[0] - cp[0], cq[1] - cp[1]], dq / (dp + dq)) } (Some(p), None) => { let cp = self.centre[p]; ([centre[0] - cp[0], centre[1] - cp[1]], 1.0) } (None, Some(q)) => { let cq = self.centre[q]; ([cq[0] - centre[0], cq[1] - centre[1]], 1.0) } (None, None) => unreachable!("a face without cells"), }; Face { n0, n1, centre, s, owner, neigh, d, w, } } }