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