mesh: one transfinite interpolation for mapped hex and mesh.block; stable oracle report (#52)

* mesh: one transfinite interpolation for mapped hex and mesh.block; stable oracle report

mesh.block's lattice is now built from the structured hex mesher's
transfinite code: faces by quad::transfinite_grid, the interior by the new
quad::transfinite_volume (Gordon-Thiel, blended by the block's graded
parameters), which hex::mapped now calls too. 2D patches are the
transfinite_grid of their sides. Edge curves return their corners exactly.

Oracle: a zero-reference quantity (rounding noise) keeps its absolute
tolerance as its fixture band, and docs/oracle.md shows such noise and
relative errors inside the fixture band as bounds, so the report no longer
changes with the order of floating-point operations (it failed the
'docs/oracle.md is current' check on main after #50). Scordelis-Lo moved by
1.4e-9 relative (an ill-conditioned thin shell of solids: sub-ulp node
changes) and is re-blessed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* simplify: shared face filling and lattice index; faces blended like the volume; exact ends for every edge

quad::transfinite_faces fills a block's six faces (with a per-face hook
that hex::mapped uses to project onto its B-rep faces) and
quad::lattice_index fixes the lattice layout for all three users.
transfinite_grid_blended blends faces by the block's graded parameters, so
faces and interior use one parametrisation and mesh.block reproduces its
earlier Gordon-Hall lattice (the Scordelis-Lo fixture is back to its
blessed value). Straight edges also return their corners exactly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:50:20 -04:00
1 parent cd5e1e9df9
commit 85ba526663
16 files changed
+331 -239

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ mod smooth;
pub mod tfi;
pub use domain::{MAX_BOUNDARY_POINTS, MAX_LOOPS};
pub use tfi::{MAX_GRID_NODES, transfinite_grid};
pub use tfi::{MAX_GRID_NODES, lattice_index, transfinite_faces, transfinite_grid, transfinite_grid_blended, transfinite_volume};
use crate::predicates::{Point2, Sign, snap_to_domain};
use crate::quality::{planar_corner, quad_axes, turns_left};
+135 -2
View File
@@ -9,7 +9,7 @@
//! `u = (1−v) u_B(i) + v u_T(i)` and `v = (1−u) v_L(j) + u v_R(j)` (the standard treatment of
//! unequal spacing on opposite sides, e.g. Thompson, Soni, Weatherill, *Handbook of Grid
//! Generation*, CRC 1999, ch. 3). The map is dimension-generic: faces of a block in space use it
//! with `D = 3`.
//! with `D = 3`, and [`transfinite_volume`] fills a block from its six face grids.
use crate::{Error, Result};
@@ -47,6 +47,33 @@ fn params<const D: usize>(side: &[[f64; D]], name: &str) -> Result<Vec<f64>> {
/// left[0]`, `bottom[nu] = right[0]`, `top[0] = left[nv]`, `top[nu] = right[nv]`). Side nodes are
/// copied exactly into the grid.
pub fn transfinite_grid<const D: usize>(bottom: &[[f64; D]], right: &[[f64; D]], top: &[[f64; D]], left: &[[f64; D]]) -> Result<Vec<[f64; D]>> {
grid(bottom, right, top, left, None)
}
/// [`transfinite_grid`] blended by given parameters (`u` along bottom and top, `v` along left and
/// right, each from 0 to 1) instead of the sides' arc lengths: the Coons patch of a map whose
/// sides are parametrised by them (a graded mapped block's lattice).
pub fn transfinite_grid_blended<const D: usize>(
bottom: &[[f64; D]],
right: &[[f64; D]],
top: &[[f64; D]],
left: &[[f64; D]],
u: &[f64],
v: &[f64],
) -> Result<Vec<[f64; D]>> {
if u.len() != bottom.len() || v.len() != left.len() {
return Err(Error::Invalid("one blending parameter per side node is needed".into()));
}
grid(bottom, right, top, left, Some((u, v)))
}
fn grid<const D: usize>(
bottom: &[[f64; D]],
right: &[[f64; D]],
top: &[[f64; D]],
left: &[[f64; D]],
blend: Option<(&[f64], &[f64])>,
) -> Result<Vec<[f64; D]>> {
let (nu, nv) = (bottom.len().saturating_sub(1), left.len().saturating_sub(1));
if nu == 0 || nv == 0 || top.len() != bottom.len() || right.len() != left.len() {
return Err(Error::Invalid(format!(
@@ -68,7 +95,10 @@ pub fn transfinite_grid<const D: usize>(bottom: &[[f64; D]], right: &[[f64; D]],
if corner(left, false) != Some(p00) || corner(right, false) != Some(p10) || corner(left, true) != Some(p01) || corner(right, true) != Some(p11) {
return Err(Error::Invalid("the sides of a mapped region must share their corner points".into()));
}
let (ub, ut, vl, vr) = (params(bottom, "bottom")?, params(top, "top")?, params(left, "left")?, params(right, "right")?);
let (ub, ut, vl, vr) = match blend {
Some((u, v)) => (u.to_vec(), u.to_vec(), v.to_vec(), v.to_vec()),
None => (params(bottom, "bottom")?, params(top, "top")?, params(left, "left")?, params(right, "right")?),
};
let mut grid = Vec::with_capacity(count);
for j in 0..=nv {
for i in 0..=nu {
@@ -106,3 +136,106 @@ pub fn transfinite_grid<const D: usize>(bottom: &[[f64; D]], right: &[[f64; D]],
}
Ok(grid)
}
/// The index of lattice node `c = [i, j, k]` in a block lattice of `n[d] + 1` nodes per direction:
/// `(k·(n[1] + 1) + j)·(n[0] + 1) + i`.
pub fn lattice_index(n: [usize; 3], c: [usize; 3]) -> usize {
(c[2] * (n[1] + 1) + c[1]) * (n[0] + 1) + c[0]
}
/// Fill the six faces of a block lattice ([`lattice_index`]) whose twelve edges are set: each face
/// is the transfinite grid of its four edges, blended by `blend` (one parameter list per
/// direction) or, without it, by the edges' arc lengths. `finish(d, side, grid)` sees each face's
/// grid before its interior nodes are stored (face `d = side · n[d]`, the grid running along the
/// other two directions in increasing order, the lower one fastest): a mesher can project them
/// onto its geometry. Edge nodes already set are kept.
pub fn transfinite_faces(
x: &mut [Option<[f64; 3]>],
n: [usize; 3],
blend: Option<[&[f64]; 3]>,
mut finish: impl FnMut(usize, usize, &mut [[f64; 3]]) -> Result<()>,
) -> Result<()> {
for d in 0..3 {
let (da, db) = match d {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let (na, nb) = (n[da], n[db]);
for side in 0..2 {
let at = |a: usize, b: usize| {
let mut c = [0; 3];
c[d] = side * n[d];
c[da] = a;
c[db] = b;
lattice_index(n, c)
};
let line = |x: &[Option<[f64; 3]>], f: &dyn Fn(usize) -> usize, m: usize| -> Result<Vec<[f64; 3]>> {
(0..=m)
.map(|q| x.get(f(q)).copied().flatten().ok_or_else(|| Error::Failed("transfinite faces: an edge node is missing".into())))
.collect()
};
let (bottom, top) = (line(x, &|a| at(a, 0), na)?, line(x, &|a| at(a, nb), na)?);
let (left, right) = (line(x, &|b| at(0, b), nb)?, line(x, &|b| at(na, b), nb)?);
let mut grid = match blend {
Some(p) => transfinite_grid_blended(&bottom, &right, &top, &left, p[da], p[db])?,
None => transfinite_grid(&bottom, &right, &top, &left)?,
};
finish(d, side, &mut grid)?;
for b in 1..nb {
for a in 1..na {
if let (Some(slot), Some(p)) = (x.get_mut(at(a, b)), grid.get(b * (na + 1) + a)) {
*slot = Some(*p);
}
}
}
}
}
Ok(())
}
/// The trivariate transfinite interpolation of a block's interior from its boundary (W. J.
/// Gordon, L. C. Thiel, *Transfinite mappings and their application to grid generation*, Applied
/// Mathematics and Computation 10–11:171–233, 1982): faces blended linearly, minus edges blended
/// bilinearly, plus corners blended trilinearly. `x` is the `(n[0] + 1) × (n[1] + 1) × (n[2] + 1)`
/// lattice, node `(i, j, k)` at `(k·(n[1] + 1) + j)·(n[0] + 1) + i`, with every boundary node set;
/// `blend[d]` holds the blending parameter of each lattice index along direction `d`, from 0 to 1
/// (`i/n` for uniform blending). Interior nodes are set; boundary nodes are left as they are.
pub fn transfinite_volume(x: &mut [Option<[f64; 3]>], n: [usize; 3], blend: [&[f64]; 3]) -> Result<()> {
let [ni, nj, nk] = n;
let total = (ni + 1).checked_mul(nj + 1).and_then(|c| c.checked_mul(nk + 1));
if total != Some(x.len()) || (0..3).any(|d| blend[d].len() != n[d] + 1) {
return Err(Error::Invalid("a transfinite volume needs a full lattice and one blending parameter per index".into()));
}
let idx = |i: usize, j: usize, k: usize| lattice_index(n, [i, j, k]);
let get = |x: &[Option<[f64; 3]>], i: usize, j: usize, k: usize| -> Result<[f64; 3]> {
x.get(idx(i, j, k)).copied().flatten().ok_or_else(|| Error::Failed("transfinite volume: a boundary node is missing".into()))
};
let lin = |t: f64, a: [f64; 3], b: [f64; 3]| [0, 1, 2].map(|c| (1.0 - t) * a[c] + t * b[c]);
let bil = |s: f64, t: f64, p00, p10, p01, p11| lin(t, lin(s, p00, p10), lin(s, p01, p11));
for k in 1..nk {
for j in 1..nj {
for i in 1..ni {
let at = |d: usize, q: usize| blend[d].get(q).copied().unwrap_or(0.0);
let (u, v, w) = (at(0, i), at(1, j), at(2, k));
let g = |i: usize, j: usize, k: usize| get(x, i, j, k);
let faces = [lin(u, g(0, j, k)?, g(ni, j, k)?), lin(v, g(i, 0, k)?, g(i, nj, k)?), lin(w, g(i, j, 0)?, g(i, j, nk)?)];
let edges = [
bil(v, w, g(i, 0, 0)?, g(i, nj, 0)?, g(i, 0, nk)?, g(i, nj, nk)?),
bil(u, w, g(0, j, 0)?, g(ni, j, 0)?, g(0, j, nk)?, g(ni, j, nk)?),
bil(u, v, g(0, 0, k)?, g(ni, 0, k)?, g(0, nj, k)?, g(ni, nj, k)?),
];
let corners = lin(
w,
bil(u, v, g(0, 0, 0)?, g(ni, 0, 0)?, g(0, nj, 0)?, g(ni, nj, 0)?),
bil(u, v, g(0, 0, nk)?, g(ni, 0, nk)?, g(0, nj, nk)?, g(ni, nj, nk)?),
);
let p = [0, 1, 2].map(|c| faces.iter().map(|f| f[c]).sum::<f64>() - edges.iter().map(|e| e[c]).sum::<f64>() + corners[c]);
if let Some(slot) = x.get_mut(idx(i, j, k)) {
*slot = Some(p);
}
}
}
}
Ok(())
}