hex: conforming multi-block meshes of partitioned parts

hex_mesh_cells meshes cells that meet face to face (SolidCraft split
bodies) as one conforming all-hex mesh: each cell a mapped block after
smoothly meeting faces are joined (virtual topology: the halves of a hole,
a plane running into a fillet), vertices, edges and faces shared by
identity, interval assignment over all cells, each shared face meshed
once, radial edges graded off curved faces. finish spans several cells:
tags per cell, hex20 mid-edge nodes shared across cells, a closed
2-manifold boundary and no free internal face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 04:56:46 -04:00
1 parent 8ca2dddeec
commit b6ce7aee69
8 files changed
+1439 -81

No files matched your search

+208 -36
View File
@@ -19,6 +19,11 @@
//! A body none of these fits is [`Error::Unsupported`] with each method's reason: the input for
//! decomposition into sweepable and mappable regions.
//!
//! **Partitioned parts.** [`hex_mesh_cells`] ([`cells`]) meshes a part split into cells that
//! meet face to face (SolidCraft split bodies) as one conforming mesh: every cell a mapped block,
//! interval assignment over all cells together, each shared face meshed once and its nodes
//! merged by identity.
//!
//! **Common steps.** Interval assignment ([`interval`]) picks the division counts of shared curves
//! together. Boundary nodes on analytic faces are placed on the exact surface; quadratic hexahedra
//! (hex20) get their mid-edge nodes projected onto the faces (and onto both faces of a B-rep
@@ -31,6 +36,7 @@
mod arrangement;
mod brep;
mod cells;
mod geom;
mod interval;
mod layered;
@@ -43,6 +49,7 @@ mod tests;
use std::collections::HashMap;
pub use cells::{Cell, CellsMesh, GROWTH, MAX_CELLS, hex_mesh_cells};
pub use geom::Surface;
use brep::Brep;
@@ -198,16 +205,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
surface.shells().len()
)));
}
// A mesh far beyond the element limit is refused before any work: the solid's volume over
// the cube of the smallest size the field asks for at the boundary.
let mut h_min = f64::INFINITY;
for p in surface.points() {
h_min = h_min.min(crate::size::checked_size(field, *p)?);
}
let estimate = surface.volume().abs() / h_min.powi(3);
if !(estimate <= 8.0 * opts.max_elements as f64) {
return Err(Error::Limit(format!("about {estimate:.3e} hexahedra at this size; the limit is {}", opts.max_elements)));
}
check_estimate(&[surface], field, opts)?;
let brep = Brep::new(surface, faces)?;
let tree = TriangleTree::new(surface)?;
let ctx = Ctx { brep: &brep, tree: &tree, field, opts, tol: RELATIVE_TOLERANCE * brep.diagonal };
@@ -253,7 +251,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
Err(e @ Error::Limit(_)) => return Err(e),
Err(e) => last = Some(e),
},
_ => match mapped::mesh(&ctx).and_then(|raw| finish(&ctx, raw, method)) {
_ => match mapped::mesh(&ctx).and_then(|raw| finish(&Cells::one(&ctx), raw, method)) {
Ok(mesh) => return Ok(mesh),
Err(e @ Error::Limit(_)) => return Err(e),
Err(e) => last = Some(e),
@@ -266,6 +264,23 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
Err(Error::Unsupported(format!("no structured hex method fits this body ({})", reasons.join("; "))))
}
/// Refuse a mesh far beyond the element limit before any work: the solids' volume over the cube
/// of the smallest size the field asks for on their boundaries.
fn check_estimate(surfaces: &[&BoundarySurface], field: &dyn SizeField, opts: &HexOptions) -> Result<()> {
let (mut h_min, mut volume) = (f64::INFINITY, 0.0);
for s in surfaces {
for p in s.points() {
h_min = h_min.min(crate::size::checked_size(field, *p)?);
}
volume += s.volume().abs();
}
let estimate = volume / h_min.powi(3);
if !(estimate <= 8.0 * opts.max_elements as f64) {
return Err(Error::Limit(format!("about {estimate:.3e} hexahedra at this size; the limit is {}", opts.max_elements)));
}
Ok(())
}
/// The frames a layered method can try, fewest levels first, or the first reason none fits.
fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<layered::Setup>, String> {
let frames = if method == HexMethod::Extrude { layered::translations(ctx.brep) } else { layered::rotations(ctx.brep) };
@@ -292,7 +307,7 @@ fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<lay
fn with_refinement(ctx: &Ctx, method: HexMethod, run: impl Fn(u32) -> Result<Raw>) -> Result<HexMesh> {
let mut last = Error::Failed("no attempt".into());
for refine in 0..=MAX_REFINE {
match run(refine).and_then(|raw| finish(ctx, raw, method)) {
match run(refine).and_then(|raw| finish(&Cells::one(ctx), raw, method)) {
Ok(mesh) => return Ok(mesh),
Err(e @ Error::Failed(_)) => last = e,
Err(e) => return Err(e),
@@ -314,14 +329,130 @@ fn hex_face(k: usize) -> [usize; 4] {
ElementKind::Hex8.faces().get(k).and_then(|f| <[usize; 4]>::try_from(*f).ok()).unwrap_or([0, 1, 2, 3])
}
/// Tag, check, upgrade to hex20 when asked, gate and measure.
fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
/// The cells a mesh spans, for [`finish`]: one context per cell and the cell of each element.
pub(crate) struct Cells<'c, 'a> {
pub ctxs: &'c [Ctx<'a>],
/// The cell of each hexahedron (index into `ctxs`); empty: all in the first.
pub of: &'c [u32],
/// The cells' names, for messages.
pub names: &'c [&'c str],
}
impl<'c, 'a> Cells<'c, 'a> {
pub(crate) fn one(ctx: &'c Ctx<'a>) -> Self {
Cells { ctxs: std::slice::from_ref(ctx), of: &[], names: &[] }
}
fn name(&self, c: usize) -> String {
self.names.get(c).map_or_else(|| c.to_string(), |n| format!("`{n}`"))
}
fn cell(&self, e: usize) -> usize {
self.of.get(e).map_or(0, |&c| c as usize)
}
fn ctx(&self, e: usize) -> Result<&Ctx<'a>> {
self.ctxs.get(self.cell(e)).ok_or_else(|| Error::Failed(format!("element {e} has no cell")))
}
}
/// Buckets of a uniform grid over the cells' bounding boxes: which cells may be near a point.
struct CellGrid {
lo: P,
step: f64,
dims: [usize; 3],
buckets: Vec<Vec<usize>>,
}
/// Cosine of the largest angle between a boundary face's normal and the reversed normal of
/// another cell's triangle for the face to lie on that triangle (about 25°): a face on another
/// cell faces it, while faces that merely meet at an edge (a fillet beside a split) do not.
const FACING: f64 = 0.9;
/// Buckets per axis of a [`CellGrid`] at most.
const MAX_GRID_DIM: usize = 64;
impl CellGrid {
/// The cells' boxes grown by `gap`.
fn new(ctxs: &[Ctx], gap: f64) -> CellGrid {
let boxes: Vec<(P, P)> = ctxs
.iter()
.map(|c| {
c.brep.surface.points().iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(mut lo, mut hi), p| {
for k in 0..3 {
lo[k] = lo[k].min(p[k] - gap);
hi[k] = hi[k].max(p[k] + gap);
}
(lo, hi)
})
})
.collect();
let (lo, hi) = boxes.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(mut lo, mut hi), (a, b)| {
for k in 0..3 {
lo[k] = lo[k].min(a[k]);
hi[k] = hi[k].max(b[k]);
}
(lo, hi)
});
let per_axis = ((ctxs.len() as f64).cbrt().ceil() as usize * 2).clamp(1, MAX_GRID_DIM);
let extent = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let step = if extent > 0.0 && extent.is_finite() { extent / per_axis as f64 } else { 1.0 };
let dim = |k: usize| (((hi[k] - lo[k]) / step).ceil() as usize).clamp(1, MAX_GRID_DIM);
let dims = [dim(0), dim(1), dim(2)];
let mut grid = CellGrid { lo, step, dims, buckets: vec![Vec::new(); dims[0] * dims[1] * dims[2]] };
for (c, (a, b)) in boxes.iter().enumerate() {
let (i0, i1) = (grid.index(*a), grid.index(*b));
for i in i0[0]..=i1[0] {
for j in i0[1]..=i1[1] {
for k in i0[2]..=i1[2] {
if let Some(bucket) = grid.buckets.get_mut((k * dims[1] + j) * dims[0] + i) {
bucket.push(c);
}
}
}
}
}
grid
}
fn index(&self, p: P) -> [usize; 3] {
[0, 1, 2].map(|k| {
let x = ((p[k] - self.lo[k]) / self.step).floor();
if x.is_finite() && x > 0.0 { (x as usize).min(self.dims[k] - 1) } else { 0 }
})
}
fn near(&self, p: P) -> &[usize] {
let [i, j, k] = self.index(p);
self.buckets.get((k * self.dims[1] + j) * self.dims[0] + i).map(Vec::as_slice).unwrap_or(&[])
}
}
/// A boundary quadrilateral of a mesh being finished: the element face, its cell and the B-rep
/// face (index into the cell's faces) it lies on.
struct Skin {
element: u32,
face: u8,
cell: usize,
brep: usize,
}
/// Tag, check, upgrade to hex20 when asked, gate and measure. Besides the gate, the tiling and
/// the fidelity checks, no boundary face of a mesh of several cells may lie on another cell's
/// boundary (a free internal face: cells that do not conform there), and the mesh boundary must
/// be a closed 2-manifold of quadrilaterals (every edge on exactly two).
fn finish(cells: &Cells, raw: Raw, method: HexMethod) -> Result<HexMesh> {
let Raw { mut nodes, hexes, description } = raw;
let first = cells.ctxs.first().ok_or_else(|| Error::Failed("no cell to finish".into()))?;
let opts = first.opts;
if hexes.is_empty() {
return Err(Error::Failed("the method produced no elements".into()));
}
if hexes.len() > ctx.opts.max_elements {
return Err(Error::Limit(format!("the mesh has {} hexahedra; the limit is {}", hexes.len(), ctx.opts.max_elements)));
if hexes.len() > opts.max_elements {
return Err(Error::Limit(format!("the mesh has {} hexahedra; the limit is {}", hexes.len(), opts.max_elements)));
}
if !cells.of.is_empty() && cells.of.len() != hexes.len() {
return Err(Error::Failed("a cell is needed for every element".into()));
}
fieldcraft_model::check_coordinates(nodes.as_flattened())?;
let at = |nodes: &[P], i: u32| nodes.get(i as usize).copied().ok_or_else(|| Error::Failed(format!("node {i} out of range")));
@@ -337,8 +468,14 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
if let Some(n) = uses.values().copied().max().filter(|&n| n > 2) {
return Err(Error::Failed(format!("a hexahedron face is shared by {n} elements (the mesh is not a manifold)")));
}
// A boundary face this close to another cell's boundary lies on it.
let gap = 2.0 * opts.chordal_tolerance + cells.ctxs.iter().map(|c| c.tol).fold(0.0, f64::max);
let others = (cells.ctxs.len() > 1).then(|| CellGrid::new(cells.ctxs, gap));
let mut skin: Vec<Skin> = Vec::new();
let mut boundary = Vec::new();
let mut fidelity = 0.0;
// Boundary edges of the quadrilaterals: each must be on exactly two of them.
let mut rim: HashMap<(u32, u32), u32> = HashMap::new();
for (e, h) in hexes.iter().enumerate() {
for k in 0..6 {
let corners = hex_face(k).map(|i| h.get(i).copied().unwrap_or(0));
@@ -347,6 +484,11 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
if uses.get(&key) != Some(&1) {
continue;
}
for m in 0..4 {
let (a, b) = (corners[m], corners[(m + 1) % 4]);
*rim.entry((a.min(b), a.max(b))).or_insert(0) += 1;
}
let ctx = cells.ctx(e)?;
let [a, b, c, d] = [at(&nodes, corners[0])?, at(&nodes, corners[1])?, at(&nodes, corners[2])?, at(&nodes, corners[3])?];
let centre = scale(add(add(a, b), add(c, d)), 0.25);
let n = cross(sub(c, a), sub(d, b));
@@ -357,14 +499,35 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
.closest(centre, &|t| dot(ctx.tree.normal(t), n) > 0.0)
.ok_or_else(|| Error::Failed(format!("no boundary face faces the same way as element {e}'s face {}", k + 1)))?;
let f = ctx.brep.face_of_triangle(hit.triangle).ok_or_else(|| Error::Failed("triangle without a face".into()))?;
fidelity += area * (hit.dist + ctx.opts.chordal_tolerance);
boundary.push(BoundaryFace { element: e as u32, face: k as u8 + 1, brep_face: ctx.brep.face_tag(f) });
let cell = cells.cell(e);
if let Some(grid) = &others {
for &o in grid.near(centre) {
let Some(other) = cells.ctxs.get(o).filter(|_| o != cell) else { continue };
if other.tree.closest_within(centre, gap, &|t| dot(other.tree.normal(t), n) < -FACING).is_some() {
return Err(Error::Failed(format!(
"element {e}'s face {} lies on cell {}'s boundary without a neighbour there: cells {} and {} do not conform (split them so that they meet face to face)",
k + 1,
cells.name(o),
cells.name(cell),
cells.name(o)
)));
}
}
}
// The geometry's own face tag (a member of a composite face keeps its own).
let tag = ctx.brep.surface.tri_face().get(hit.triangle as usize).copied().unwrap_or_else(|| ctx.brep.face_tag(f));
fidelity += area * (hit.dist + opts.chordal_tolerance);
boundary.push(BoundaryFace { element: e as u32, face: k as u8 + 1, brep_face: tag });
skin.push(Skin { element: e as u32, face: k as u8 + 1, cell, brep: f });
}
}
let kind = if ctx.opts.quadratic { ElementKind::Hex20 } else { ElementKind::Hex8 };
if let Some((&(a, b), &n)) = rim.iter().filter(|(_, n)| **n != 2).min_by_key(|(k, _)| **k) {
return Err(Error::Failed(format!("the mesh boundary is not closed: the edge between nodes {a} and {b} is on {n} boundary faces")));
}
let kind = if opts.quadratic { ElementKind::Hex20 } else { ElementKind::Hex8 };
let mut connectivity: Vec<u32> = Vec::with_capacity(hexes.len() * kind.node_count());
if ctx.opts.quadratic {
let mids = mid_edge_nodes(ctx, &mut nodes, &hexes, &boundary)?;
if opts.quadratic {
let mids = mid_edge_nodes(cells, &mut nodes, &hexes, &skin)?;
for (h, m) in hexes.iter().zip(&mids) {
connectivity.extend_from_slice(h);
connectivity.extend_from_slice(m);
@@ -398,7 +561,7 @@ fn finish(ctx: &Ctx, raw: Raw, method: HexMethod) -> Result<HexMesh> {
let gate = validity_gate_checks(checks.iter().map(|(l, c)| (*l, c)), &GateOptions { expected_volume: Some(enclosed), volume_tolerance: 1e-9 })?
.into_result()
.map_err(|e| Error::Failed(format!("{} ({description})", plain(&e))))?;
let surface_volume = ctx.brep.surface.volume();
let surface_volume: f64 = cells.ctxs.iter().map(|c| c.brep.surface.volume()).sum();
let slack = 2.0 * fidelity + 1e-9 * surface_volume.abs();
if (gate.volume - surface_volume).abs() > slack {
return Err(Error::Failed(format!(
@@ -478,46 +641,52 @@ pub(crate) fn project_to_face(ctx: &Ctx, f: usize, p: P) -> Option<P> {
return Some(q);
}
let tag = face.id;
let tri_face = ctx.brep.surface.tri_face();
ctx.tree.closest(p, &|t| tri_face.get(t as usize) == Some(&tag)).map(|h| h.point)
ctx.tree.closest(p, &|t| ctx.brep.tag(t) == Some(tag)).map(|h| h.point)
}
/// The point on every listed face (their common curve or corner) nearest `p`, by alternating
/// projections.
pub(crate) fn project_to_faces(ctx: &Ctx, faces: &[usize], p: P) -> P {
let pairs: Vec<(&Ctx, usize)> = faces.iter().map(|&f| (ctx, f)).collect();
project_alternating(&pairs, ctx.tol, p)
}
/// The point on every listed face of the listed cells nearest `p`, by alternating projections
/// until a round moves it less than a thousandth of `tol`.
fn project_alternating(faces: &[(&Ctx, usize)], tol: f64, p: P) -> P {
let mut x = p;
for _ in 0..PROJECTION_ROUNDS {
let before = x;
for &f in faces {
for &(ctx, f) in faces {
if let Some(q) = project_to_face(ctx, f, x) {
x = q;
}
}
if faces.len() < 2 || dist(before, x) <= ctx.tol * 1e-3 {
if faces.len() < 2 || dist(before, x) <= tol * 1e-3 {
break;
}
}
x
}
/// Mid-edge nodes of every hexahedron (Hex20 order: positions 8..20), shared between elements;
/// boundary edges get theirs on the B-rep faces of the boundary quads around them.
fn mid_edge_nodes(ctx: &Ctx, nodes: &mut Vec<P>, hexes: &[[u32; 8]], boundary: &[BoundaryFace]) -> Result<Vec<[u32; 12]>> {
// B-rep faces at each boundary edge.
let mut edge_faces: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
for b in boundary {
/// Mid-edge nodes of every hexahedron (Hex20 order: positions 8..20), shared between elements
/// (of any cell); boundary edges get theirs on the B-rep faces of the boundary quads around them.
fn mid_edge_nodes(cells: &Cells, nodes: &mut Vec<P>, hexes: &[[u32; 8]], skin: &[Skin]) -> Result<Vec<[u32; 12]>> {
// B-rep faces (cell, face) at each boundary edge.
let mut edge_faces: HashMap<(u32, u32), Vec<(usize, usize)>> = HashMap::new();
for b in skin {
let Some(h) = hexes.get(b.element as usize) else { continue };
let c = hex_face(b.face as usize - 1).map(|i| h.get(i).copied().unwrap_or(0));
let f = ctx.brep.faces.binary_search_by_key(&b.brep_face, |x| x.id).map_err(|_| Error::Failed("boundary tag".into()))?;
for k in 0..4 {
let (a, z) = (c[k], c[(k + 1) % 4]);
let list = edge_faces.entry((a.min(z), a.max(z))).or_default();
if !list.contains(&f) {
list.push(f);
if !list.contains(&(b.cell, b.brep)) {
list.push((b.cell, b.brep));
}
}
}
let edges = ElementKind::Hex20.edges();
let tol = cells.ctxs.iter().map(|c| c.tol).fold(f64::INFINITY, f64::min);
let mut mid: HashMap<(u32, u32), u32> = HashMap::new();
let mut out = Vec::with_capacity(hexes.len());
for h in hexes {
@@ -533,7 +702,10 @@ fn mid_edge_nodes(ctx: &Ctx, nodes: &mut Vec<P>, hexes: &[[u32; 8]], boundary: &
let (a, b) = (nodes.get(i as usize).copied().unwrap_or([0.0; 3]), nodes.get(j as usize).copied().unwrap_or([0.0; 3]));
let p = scale(add(a, b), 0.5);
let p = match edge_faces.get(&key) {
Some(fs) => project_to_faces(ctx, fs, p),
Some(fs) => {
let pairs: Vec<(&Ctx, usize)> = fs.iter().filter_map(|&(c, f)| cells.ctxs.get(c).map(|ctx| (ctx, f))).collect();
project_alternating(&pairs, tol, p)
}
None => p,
};
nodes.push(p);