Skip to main content

outram_foam_basic_lib/steady_conduction/
mesh.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
18
19//! Meshes for the steady conduction solver (gh:#949).
20//!
21//! - [`radial_mesh`]: a one-dimensional slab, cylinder or sphere in nested
22//!   shells, with the true face areas (`A`, `2 pi r H`, `4 pi r^2`) and shell
23//!   volumes. The verification mesh: [`super::closed_form`] is exact on it.
24//! - [`voxel_mesh`]: cubic voxels over a box, keeping the voxels whose centre
25//!   a caller-supplied function puts inside the body. Any shape, at the cost
26//!   of a staircase surface. This is the beginner thermal profile's mesh.
27//!
28//! Both put faces in OpenFOAM's upper-triangular order (`owner < neighbour`,
29//! sorted by owner), which the DIC-preconditioned conjugate gradient needs.
30//!
31//! **Mesh-nexus note (gh:#492).** `outram_blender::unstructured` is the
32//! workspace's neutral FV/FE/MC mesh, but this crate may not depend on
33//! `outram-blender` (#486 rule 1), and a structured voxel grid needs only the
34//! [`FvMesh`] built here directly. Exporting a [`VoxelMesh`] to the neutral
35//! mesh is left to the crate that owns that dependency.
36
37use std::sync::Arc;
38
39use uom::si::area::square_meter;
40use uom::si::f64::{Area, Length};
41use uom::si::length::meter;
42
43use super::SteadyConductionError;
44use crate::mesh::fv_mesh::{BoundaryPatch, FvMesh, FvMeshBuilder, PatchKind};
45use crate::primitives::Vector3;
46
47/// A mesh plus the zone (material region) of every cell.
48#[derive(Debug, Clone)]
49pub struct ConductionMesh {
50    /// The finite-volume mesh, lengths in metres.
51    pub mesh: Arc<FvMesh>,
52    /// Zone index of each cell; zones carry the heat source and conductivity.
53    pub cell_zone: Vec<usize>,
54    /// Number of zones (`max(cell_zone) + 1`, or more if a zone has no cell).
55    pub n_zones: usize,
56}
57
58/// The shape of a one-dimensional radial body.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub enum RadialGeometry {
61    /// A plate, symmetric about its mid-plane; the radius is the distance from
62    /// the mid-plane, and `area` is the plate's face area.
63    Slab {
64        /// Face area of the plate.
65        area: Area,
66    },
67    /// A cylinder of the given height, insulated at its ends.
68    Cylinder {
69        /// Height (axial length).
70        height: Length,
71    },
72    /// A sphere.
73    Sphere,
74}
75
76impl RadialGeometry {
77    /// The exponent `m` of the measure `r^m`: slab 0, cylinder 1, sphere 2.
78    #[must_use]
79    pub fn exponent(self) -> i32 {
80        match self {
81            Self::Slab { .. } => 0,
82            Self::Cylinder { .. } => 1,
83            Self::Sphere => 2,
84        }
85    }
86
87    /// Face area at radius `r` (m), m^2.
88    fn area_at(self, r: f64) -> f64 {
89        match self {
90            Self::Slab { area } => area.get::<square_meter>(),
91            Self::Cylinder { height } => 2.0 * std::f64::consts::PI * r * height.get::<meter>(),
92            Self::Sphere => 4.0 * std::f64::consts::PI * r * r,
93        }
94    }
95
96    /// Volume between radii `r1 < r2` (m), m^3.
97    fn volume_between(self, r1: f64, r2: f64) -> f64 {
98        match self {
99            Self::Slab { area } => area.get::<square_meter>() * (r2 - r1),
100            Self::Cylinder { height } => {
101                std::f64::consts::PI * height.get::<meter>() * (r2 * r2 - r1 * r1)
102            }
103            Self::Sphere => 4.0 / 3.0 * std::f64::consts::PI * (r2.powi(3) - r1.powi(3)),
104        }
105    }
106}
107
108/// One shell of a radial mesh: out to `outer_radius`, in `cells` equal steps.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct RadialShell {
111    /// Outer radius of the shell.
112    pub outer_radius: Length,
113    /// Number of equal-thickness cells across the shell (at least 1).
114    pub cells: usize,
115}
116
117/// Name of the outer-surface patch of a [`radial_mesh`] (patch index 0).
118pub const RADIAL_SURFACE_PATCH: &str = "surface";
119/// Name of the centre (symmetry) patch of a [`radial_mesh`] (patch index 1).
120/// Its face has zero area for a cylinder or sphere.
121pub const RADIAL_CENTRE_PATCH: &str = "centre";
122
123/// Build a one-dimensional radial mesh of nested shells (innermost first).
124///
125/// Shell `j` becomes zone `j`. Cell centres sit at the mid-radius of each cell,
126/// laid along the x axis; faces carry the true area at their radius and cells
127/// the true shell volume, so the finite-volume Laplacian is the standard
128/// conservative curvilinear scheme. Patches: index 0 [`RADIAL_SURFACE_PATCH`]
129/// (outer face), index 1 [`RADIAL_CENTRE_PATCH`] (`r = 0`).
130///
131/// # Errors
132/// [`SteadyConductionError::InvalidInput`] for no shells, a shell with zero
133/// cells, or radii not strictly increasing; [`SteadyConductionError::Mesh`]
134/// if the assembled mesh fails validation.
135pub fn radial_mesh(
136    geometry: RadialGeometry,
137    shells: &[RadialShell],
138) -> Result<ConductionMesh, SteadyConductionError> {
139    if shells.is_empty() {
140        return Err(SteadyConductionError::InvalidInput(
141            "radial mesh needs a shell".into(),
142        ));
143    }
144    // Cell edges and zones.
145    let mut edges = vec![0.0_f64];
146    let mut cell_zone = Vec::new();
147    for (j, shell) in shells.iter().enumerate() {
148        let r_in = edges[edges.len() - 1];
149        let r_out = shell.outer_radius.get::<meter>();
150        if shell.cells == 0 || !(r_out > r_in) || !r_out.is_finite() {
151            return Err(SteadyConductionError::InvalidInput(format!(
152                "shell {j}: needs at least one cell and an outer radius above {r_in} m (got {} cells, {r_out} m)",
153                shell.cells
154            )));
155        }
156        let dr = (r_out - r_in) / shell.cells as f64;
157        for i in 1..=shell.cells {
158            edges.push(if i == shell.cells {
159                r_out
160            } else {
161                r_in + dr * i as f64
162            });
163            cell_zone.push(j);
164        }
165    }
166    let n = cell_zone.len();
167    let axis = |x: f64| Vector3::new(x, 0.0, 0.0);
168
169    let cell_volumes: Vec<f64> = (0..n)
170        .map(|c| geometry.volume_between(edges[c], edges[c + 1]))
171        .collect();
172    let cell_centres: Vec<Vector3> = (0..n)
173        .map(|c| axis(0.5 * (edges[c] + edges[c + 1])))
174        .collect();
175
176    // Faces: internal 0..n-1 (between cell c and c+1, at edges[c+1]), then
177    // the surface face (owner n-1), then the centre face (owner 0).
178    let mut owner: Vec<usize> = (0..n - 1).collect();
179    let neighbour: Vec<usize> = (1..n).collect();
180    let mut face_area_vectors: Vec<Vector3> = (0..n - 1)
181        .map(|c| axis(geometry.area_at(edges[c + 1])))
182        .collect();
183    let mut face_centres: Vec<Vector3> = (0..n - 1).map(|c| axis(edges[c + 1])).collect();
184    owner.push(n - 1);
185    face_area_vectors.push(axis(geometry.area_at(edges[n])));
186    face_centres.push(axis(edges[n]));
187    owner.push(0);
188    face_area_vectors.push(axis(-geometry.area_at(0.0)));
189    face_centres.push(axis(0.0));
190
191    let centre_kind = match geometry {
192        RadialGeometry::Slab { .. } => PatchKind::Symmetry,
193        _ => PatchKind::Patch,
194    };
195    let mesh = FvMeshBuilder::new()
196        .n_cells(n)
197        .n_internal_faces(n - 1)
198        .owner(owner)
199        .neighbour(neighbour)
200        .patches(vec![
201            BoundaryPatch::new(RADIAL_SURFACE_PATCH, n - 1, 1, PatchKind::Patch),
202            BoundaryPatch::new(RADIAL_CENTRE_PATCH, n, 1, centre_kind),
203        ])
204        .cell_volumes(cell_volumes)
205        .cell_centres(cell_centres)
206        .face_area_vectors(face_area_vectors)
207        .face_centres(face_centres)
208        .build()
209        .map_err(|e| SteadyConductionError::Mesh(format!("{e:?}")))?;
210    Ok(ConductionMesh {
211        mesh: Arc::new(mesh),
212        cell_zone,
213        n_zones: shells.len(),
214    })
215}
216
217/// A box of cubic voxels.
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct VoxelGrid {
220    /// Lowest corner of the box (x, y, z).
221    pub origin: [Length; 3],
222    /// Edge length of every voxel.
223    pub spacing: Length,
224    /// Voxel counts along x, y and z.
225    pub counts: [usize; 3],
226}
227
228impl VoxelGrid {
229    /// Flat index of voxel `(i, j, k)`, x fastest.
230    #[must_use]
231    pub fn flat(&self, ijk: [usize; 3]) -> usize {
232        ijk[0] + self.counts[0] * (ijk[1] + self.counts[1] * ijk[2])
233    }
234
235    /// Centre of voxel `(i, j, k)`.
236    #[must_use]
237    pub fn centre(&self, ijk: [usize; 3]) -> [Length; 3] {
238        let h = self.spacing;
239        [0, 1, 2].map(|a| self.origin[a] + h * (ijk[a] as f64 + 0.5))
240    }
241
242    /// The voxel containing point `p`, or `None` outside the box.
243    #[must_use]
244    pub fn voxel_of(&self, p: [Length; 3]) -> Option<[usize; 3]> {
245        let h = self.spacing.get::<meter>();
246        let mut ijk = [0usize; 3];
247        for a in 0..3 {
248            let s = (p[a] - self.origin[a]).get::<meter>() / h;
249            if !(s >= 0.0) || s >= self.counts[a] as f64 {
250                return None;
251            }
252            ijk[a] = s as usize;
253        }
254        Some(ijk)
255    }
256}
257
258/// A voxel mesh: the conduction mesh plus the map between voxels and cells.
259///
260/// All boundary faces (body to outside, and body to box edge) form one patch,
261/// index 0, named [`VOXEL_SURFACE_PATCH`].
262#[derive(Debug, Clone)]
263pub struct VoxelMesh {
264    /// The mesh and the zone of each cell.
265    pub conduction: ConductionMesh,
266    /// The grid it was cut from.
267    pub grid: VoxelGrid,
268    /// Mesh cell of each voxel (flat index, x fastest), `None` outside the body.
269    pub voxel_cell: Vec<Option<usize>>,
270    /// Voxel `(i, j, k)` of each mesh cell.
271    pub cell_voxel: Vec<[usize; 3]>,
272    /// Outward unit axis normal of each surface face (patch order).
273    pub surface_normal: Vec<[f64; 3]>,
274}
275
276/// Name of the single boundary patch of a [`voxel_mesh`].
277pub const VOXEL_SURFACE_PATCH: &str = "surface";
278
279impl VoxelMesh {
280    /// The mesh cell containing point `p`, or `None` outside the body.
281    #[must_use]
282    pub fn cell_at(&self, p: [Length; 3]) -> Option<usize> {
283        self.grid
284            .voxel_of(p)
285            .and_then(|ijk| self.voxel_cell[self.grid.flat(ijk)])
286    }
287
288    /// Number of surface faces (the size of patch 0).
289    #[must_use]
290    pub fn n_surface_faces(&self) -> usize {
291        self.surface_normal.len()
292    }
293}
294
295/// Cut a voxel mesh of a body out of `grid`.
296///
297/// `zone_at(p)` returns the zone of the body at point `p` (a voxel centre), or
298/// `None` where there is no conducting body (coolant, void, outside). Every
299/// voxel with a zone becomes a cell; every face between a cell and a non-cell
300/// (or the box edge) is a surface face.
301///
302/// The surface is a **staircase**: its area does not converge to the true
303/// area under refinement (a voxelised sphere's tends to 3/2 of it). A caller
304/// that cools the surface convectively should pass the per-face
305/// film-area fraction `|n_axis . n_true|` to the solver (see
306/// [`super::PatchCondition::with_film_area_fraction`]); the sum of
307/// `area x fraction` then converges to the true area.
308///
309/// # Errors
310/// [`SteadyConductionError::InvalidInput`] for a zero count or non-positive
311/// spacing, or when no voxel is inside the body;
312/// [`SteadyConductionError::Mesh`] if the mesh fails validation.
313pub fn voxel_mesh<F>(grid: &VoxelGrid, zone_at: F) -> Result<VoxelMesh, SteadyConductionError>
314where
315    F: Fn([Length; 3]) -> Option<usize>,
316{
317    let h = grid.spacing.get::<meter>();
318    if grid.counts.contains(&0) || !(h > 0.0) || !h.is_finite() {
319        return Err(SteadyConductionError::InvalidInput(format!(
320            "voxel grid needs non-zero counts and a positive spacing (got {:?}, {h} m)",
321            grid.counts
322        )));
323    }
324    let [nx, ny, nz] = grid.counts;
325    let n_vox = nx * ny * nz;
326    let mut voxel_cell = vec![None; n_vox];
327    let mut cell_voxel = Vec::new();
328    let mut cell_zone = Vec::new();
329    for k in 0..nz {
330        for j in 0..ny {
331            for i in 0..nx {
332                if let Some(z) = zone_at(grid.centre([i, j, k])) {
333                    voxel_cell[grid.flat([i, j, k])] = Some(cell_voxel.len());
334                    cell_voxel.push([i, j, k]);
335                    cell_zone.push(z);
336                }
337            }
338        }
339    }
340    let n = cell_voxel.len();
341    if n == 0 {
342        return Err(SteadyConductionError::InvalidInput(
343            "no voxel centre lies inside the body; refine the grid or check the box".into(),
344        ));
345    }
346    let n_zones = cell_zone.iter().copied().max().unwrap_or(0) + 1;
347    let o = grid.origin.map(|l| l.get::<meter>());
348    let centre = |ijk: [usize; 3]| {
349        Vector3::new(
350            o[0] + h * (ijk[0] as f64 + 0.5),
351            o[1] + h * (ijk[1] as f64 + 0.5),
352            o[2] + h * (ijk[2] as f64 + 0.5),
353        )
354    };
355    let unit = |a: usize, s: f64| {
356        let mut v = [0.0; 3];
357        v[a] = s;
358        v
359    };
360    let area = h * h;
361
362    let mut owner = Vec::new();
363    let mut neighbour = Vec::new();
364    let mut sf = Vec::new();
365    let mut cf = Vec::new();
366    // Boundary faces collected separately and appended after the internal ones.
367    let mut b_owner = Vec::new();
368    let mut b_sf = Vec::new();
369    let mut b_cf = Vec::new();
370    let mut surface_normal = Vec::new();
371    for (c, ijk) in cell_voxel.iter().enumerate() {
372        let cc = centre(*ijk);
373        for a in 0..3 {
374            for s in [-1.0_f64, 1.0] {
375                let n_hat = unit(a, s);
376                let n_vec = Vector3::new(n_hat[0], n_hat[1], n_hat[2]);
377                let neighbour_voxel = if s < 0.0 {
378                    ijk[a].checked_sub(1).map(|v| {
379                        let mut t = *ijk;
380                        t[a] = v;
381                        t
382                    })
383                } else if ijk[a] + 1 < grid.counts[a] {
384                    let mut t = *ijk;
385                    t[a] += 1;
386                    Some(t)
387                } else {
388                    None
389                };
390                let nb_cell = neighbour_voxel.and_then(|t| voxel_cell[grid.flat(t)]);
391                match nb_cell {
392                    // Internal face: stored once, from the lower-index cell
393                    // (the +direction neighbour always has the higher index).
394                    Some(nb) if s > 0.0 => {
395                        owner.push(c);
396                        neighbour.push(nb);
397                        sf.push(n_vec * area);
398                        cf.push(cc + n_vec * (0.5 * h));
399                    }
400                    Some(_) => {}
401                    None => {
402                        b_owner.push(c);
403                        b_sf.push(n_vec * area);
404                        b_cf.push(cc + n_vec * (0.5 * h));
405                        surface_normal.push(n_hat);
406                    }
407                }
408            }
409        }
410    }
411    let n_internal = owner.len();
412    let n_boundary = b_owner.len();
413    owner.extend(b_owner);
414    sf.extend(b_sf);
415    cf.extend(b_cf);
416    let mesh = FvMeshBuilder::new()
417        .n_cells(n)
418        .n_internal_faces(n_internal)
419        .owner(owner)
420        .neighbour(neighbour)
421        .patches(vec![BoundaryPatch::new(
422            VOXEL_SURFACE_PATCH,
423            n_internal,
424            n_boundary,
425            PatchKind::Patch,
426        )])
427        .cell_volumes(vec![h * h * h; n])
428        .cell_centres(cell_voxel.iter().map(|v| centre(*v)).collect())
429        .face_area_vectors(sf)
430        .face_centres(cf)
431        .build()
432        .map_err(|e| SteadyConductionError::Mesh(format!("{e:?}")))?;
433    Ok(VoxelMesh {
434        conduction: ConductionMesh {
435            mesh: Arc::new(mesh),
436            cell_zone,
437            n_zones,
438        },
439        grid: *grid,
440        voxel_cell,
441        cell_voxel,
442        surface_normal,
443    })
444}