mesh: unstructured all-hex octree mesher

`fieldcraft_mesh::octree::octree_hex_mesh` meshes any closed part with hex8 only:

- an adaptive 3-refinement grid (27-tree) with strong balance and closure, filled with
  conforming node-marking transition templates (Schneiders; checked over all 48 symmetries);
- a core of grid cells kept clear of the boundary (the HybridOctree buffer rule), with
  non-manifold cleanup and a genus check against the part;
- a feature graph from the B-rep (corners, curves, patches) and a partition-first map of the
  core's boundary onto it, then two boundary layers: a padding layer and a pillow per patch,
  so every face boundary and sharp edge is captured by element edges;
- a Garanzha-type untangling and fitting energy (shape term |J|^3/chi, a volume term and a
  soft fitting term, deterministic across platforms), a compass search on the worst
  elements and a certified repair; failures refine the grid near them and rebuild, within
  bounded attempts and growth.

The output passes the certified Jacobian check of every element and a gate that the
boundary encloses the elements' volume; boundary quads carry their B-rep faces. Quality is
reported with the same scaled Jacobian as mesh.quality. Literature notes for each step are in
docs/meshing/literature.md (B1.6-B1.10).

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 07:38:11 -04:00
1 parent 741a803785
commit 3587b0de74
12 files changed
+3676 -7

No files matched your search

+2 -2
View File
@@ -37,7 +37,7 @@
mod arrangement;
mod brep;
mod cells;
mod geom;
pub(crate) mod geom;
mod interval;
mod layered;
mod mapped;
@@ -583,7 +583,7 @@ fn finish(cells: &Cells, raw: Raw, method: HexMethod) -> Result<HexMesh> {
/// `∫ x n_x dA` over a quad4 or quad8 face (outward normal): its contribution to the enclosed
/// volume. The 3 × 3 Gauss rule integrates it exactly (degree at most 5 per direction).
fn face_flux(face: &[P]) -> f64 {
pub(crate) fn face_flux(face: &[P]) -> f64 {
const G: [(f64, f64); 3] = [(-0.774_596_669_241_483_4, 5.0 / 9.0), (0.0, 8.0 / 9.0), (0.774_596_669_241_483_4, 5.0 / 9.0)];
let mut sum = 0.0;
for (r, wr) in G {
+1
View File
@@ -12,6 +12,7 @@ pub mod cdt;
pub mod delaunay;
pub mod hex;
pub mod join;
pub mod octree;
pub mod planar;
pub mod quad;
pub mod quality;
+636
View File
@@ -0,0 +1,636 @@
//! The boundary as the hex mesher sees it: a triangle tree for nearest-point, ray and box
//! queries, the exact inside test, and the feature graph (corners, curves, patches).
//!
//! # Feature graph
//! Every feature edge of the [`BoundarySurface`] (between two B-rep faces, or a crease inside a
//! face) whose dihedral angle reaches the sharp angle is kept; smooth face boundaries (a fillet
//! meeting its faces tangentially) are not features, the faces on either side share a patch.
//! Neither are blades and slits, edges whose normals turn by more than the blade angle (a 3°
//! wedge, a sphere touching a plane): no valid hexahedra can follow them, so the mesh rounds
//! them within the boundary tolerance. *Corners* are vertices where the number of feature edges is not two, or where a
//! curve turns by more than [`CORNER_TURN`]; *curves* are the chains of feature edges between
//! corners, or closed loops; *patches* are the connected sets of triangles between feature edges,
//! so every patch lies in one B-rep face and is bounded by curves. This is the partition the hex
//! mesh's boundary must reproduce (Gao, Shen, Panozzo 2019, §3.3).
use std::collections::BTreeMap;
pub(crate) use crate::hex::geom::closest_on_triangle;
use crate::predicates::{Axis, Point3, Sign, orient2d_projected, orient3d, snap_to_domain};
use crate::surface::BoundarySurface;
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
/// A degree-2 feature vertex whose curve turns by more than this (degrees) is a corner.
pub(crate) const CORNER_TURN: f64 = 30.0;
/// Triangles per tree leaf.
const LEAF: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Aabb {
pub lo: P,
pub hi: P,
}
impl Aabb {
pub const EMPTY: Aabb = Aabb { lo: [f64::INFINITY; 3], hi: [f64::NEG_INFINITY; 3] };
pub fn add(&mut self, p: P) {
for ((lo, hi), x) in self.lo.iter_mut().zip(&mut self.hi).zip(p) {
*lo = lo.min(x);
*hi = hi.max(x);
}
}
fn join(&mut self, o: &Aabb) {
self.add(o.lo);
self.add(o.hi);
}
pub fn overlaps(&self, o: &Aabb) -> bool {
(0..3).all(|k| self.lo[k] <= o.hi[k] && o.lo[k] <= self.hi[k])
}
/// Squared distance from a point to the box (0 inside).
fn dist2(&self, p: P) -> f64 {
(0..3).map(|k| (self.lo[k] - p[k]).max(0.0).max(p[k] - self.hi[k])).map(|d| d * d).sum()
}
/// Whether the segment `a + t (b − a)`, `t ∈ [0, 1]`, may meet the box (slab test, widened by
/// a relative margin so rounding never drops a hit).
fn meets_segment(&self, a: P, b: P) -> bool {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for k in 0..3 {
let pad = 1e-9 * (self.hi[k] - self.lo[k]).abs().max(1e-300) + 1e-12 * (a[k].abs() + b[k].abs());
let (lo, hi) = (self.lo[k] - pad, self.hi[k] + pad);
let d = b[k] - a[k];
if d == 0.0 {
if a[k] < lo || a[k] > hi {
return false;
}
continue;
}
let (mut u, mut v) = ((lo - a[k]) / d, (hi - a[k]) / d);
if u > v {
std::mem::swap(&mut u, &mut v);
}
t0 = t0.max(u);
t1 = t1.min(v);
if t0 > t1 {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy)]
struct Node {
bounds: Aabb,
children: Option<usize>,
start: usize,
end: usize,
}
/// A median-split tree over triangle boxes.
#[derive(Debug, Clone)]
struct Tree {
nodes: Vec<Node>,
order: Vec<u32>,
}
impl Tree {
fn build(boxes: &[Aabb]) -> Tree {
let mut order: Vec<u32> = (0..boxes.len() as u32).collect();
let empty = Node { bounds: Aabb::EMPTY, children: None, start: 0, end: 0 };
let mut nodes = vec![empty];
let mut work = vec![(0usize, 0usize, boxes.len())];
while let Some((ni, start, end)) = work.pop() {
let Some(slice) = order.get_mut(start..end) else { continue };
let (mut bounds, mut centres) = (Aabb::EMPTY, Aabb::EMPTY);
for &t in slice.iter() {
if let Some(b) = boxes.get(t as usize) {
bounds.join(b);
centres.add([0, 1, 2].map(|k| b.lo[k] + b.hi[k]));
}
}
let mut node = Node { bounds, children: None, start, end };
if end - start > LEAF {
let ext = [0, 1, 2].map(|k| centres.hi[k] - centres.lo[k]);
let axis = if ext[0] >= ext[1] && ext[0] >= ext[2] {
0
} else if ext[1] >= ext[2] {
1
} else {
2
};
let key = |t: &u32| boxes.get(*t as usize).map_or(0.0, |b| b.lo[axis] + b.hi[axis]);
let mid = (end - start) / 2;
slice.select_nth_unstable_by(mid, |a, b| key(a).total_cmp(&key(b)).then(a.cmp(b)));
let l = nodes.len();
nodes.push(empty);
nodes.push(empty);
node.children = Some(l);
work.push((l, start, start + mid));
work.push((l + 1, start + mid, end));
}
if let Some(n) = nodes.get_mut(ni) {
*n = node;
}
}
Tree { nodes, order }
}
fn leaf(&self, n: &Node) -> &[u32] {
self.order.get(n.start..n.end).unwrap_or(&[])
}
}
pub(crate) fn lerp(a: P, b: P, t: f64) -> P {
[0, 1, 2].map(|k| a[k] + (b[k] - a[k]) * t)
}
/// The closest point of segment `ab` to `p` and its parameter.
pub(crate) fn closest_on_segment(p: P, a: P, b: P) -> (P, f64) {
let ab = sub(b, a);
let l2 = dot(ab, ab);
let t = if l2 > 0.0 { (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
(lerp(a, b, t), t)
}
pub(crate) fn dist2(a: P, b: P) -> f64 {
let d = sub(a, b);
dot(d, d)
}
/// A chain of feature edges.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Curve {
/// Surface vertices in order; a closed loop repeats its first vertex at the end.
pub vertices: Vec<u32>,
pub closed: bool,
/// The patches on either side (sorted, deduplicated: one entry for a crease seam).
pub patches: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Patch {
pub triangles: Vec<u32>,
/// The B-rep faces of its triangles (sorted, deduplicated; one for a well-formed boundary).
pub faces: Vec<u32>,
pub area: f64,
/// Twice the area over the length of the bounding curves: the width of a strip, the
/// diameter of a disc, `∞` for a closed surface without curves.
pub width: f64,
}
/// The boundary with its queries and feature graph.
pub(crate) struct Geometry<'a> {
pub surface: &'a BoundarySurface,
tree: Tree,
pub boxes: Vec<Aabb>,
pub normals: Vec<P>,
pub tri_patch: Vec<u32>,
pub patches: Vec<Patch>,
pub curves: Vec<Curve>,
/// Corner vertices, ascending.
pub corners: Vec<u32>,
pub bounds: Aabb,
/// Far enough to leave the boundary from any point of its box.
far: f64,
}
impl<'a> Geometry<'a> {
pub fn new(surface: &'a BoundarySurface, sharp_angle: f64, blade_angle: f64) -> Result<Geometry<'a>> {
let pts = surface.points();
let tris = surface.triangles();
let xyz = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
let mut bounds = Aabb::EMPTY;
let mut boxes = Vec::with_capacity(tris.len());
let mut normals = Vec::with_capacity(tris.len());
for t in tris {
let mut b = Aabb::EMPTY;
for &v in t {
b.add(xyz(v));
}
bounds.join(&b);
boxes.push(b);
let n = cross(sub(xyz(t[1]), xyz(t[0])), sub(xyz(t[2]), xyz(t[0])));
let l = norm(n);
normals.push(if l > 0.0 { n.map(|c| c / l) } else { [0.0; 3] });
}
if tris.is_empty() || !(0..3).all(|k| bounds.hi[k] >= bounds.lo[k]) {
return Err(Error::Invalid("the boundary has no triangles".into()));
}
let tree = Tree::build(&boxes);
let diag = norm(sub(bounds.hi, bounds.lo));
let far = 4.0 * diag.max(1e-9) + (0..3).map(|k| bounds.lo[k].abs().max(bounds.hi[k].abs())).fold(0.0, f64::max);
// Feature edges and corners.
// A boundary between two B-rep faces is kept or dropped as a whole, by the median angle
// along it, so tessellation noise never breaks a curve into pieces; creases inside a
// face go edge by edge.
let face_of = |t: u32| surface.tri_face().get(t as usize).copied().unwrap_or(0);
let mut pair_angles: BTreeMap<(u32, u32), Vec<f64>> = BTreeMap::new();
for e in surface.feature_edges() {
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
if a != b {
pair_angles.entry((a.min(b), a.max(b))).or_default().push(e.angle);
}
}
let pair_kept: BTreeMap<(u32, u32), bool> = pair_angles
.into_iter()
.map(|(k, mut v)| {
v.sort_by(f64::total_cmp);
let m = v.get(v.len() / 2).copied().unwrap_or(0.0);
(k, m >= sharp_angle && m <= blade_angle)
})
.collect();
let kept = |e: &&crate::surface::FeatureEdge| {
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
if a != b { pair_kept.get(&(a.min(b), a.max(b))).copied().unwrap_or(false) } else { e.angle >= sharp_angle && e.angle <= blade_angle }
};
let mut edge_set: BTreeMap<(u32, u32), [u32; 2]> = BTreeMap::new();
for e in surface.feature_edges().iter().filter(kept) {
edge_set.insert((e.vertices[0], e.vertices[1]), e.triangles);
}
let mut adj: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for &(a, b) in edge_set.keys() {
adj.entry(a).or_default().push(b);
adj.entry(b).or_default().push(a);
}
let mut corners: Vec<u32> = adj
.iter()
.filter(|(v, n)| {
if n.len() != 2 {
return true;
}
let (p, a, b) = (xyz(**v), xyz(n[0]), xyz(n[1]));
let (u, w) = (sub(p, a), sub(b, p));
crate::vec3::angle_deg(u, w) > CORNER_TURN
})
.map(|(v, _)| *v)
.collect();
corners.sort_unstable();
// Patches: flood fill across non-feature edges.
let neighbors = surface.neighbors();
let mut tri_patch = vec![u32::MAX; tris.len()];
let mut patches: Vec<Patch> = Vec::new();
for seed in 0..tris.len() {
if tri_patch[seed] != u32::MAX {
continue;
}
let id = patches.len() as u32;
let mut patch = Patch { triangles: Vec::new(), faces: Vec::new(), area: 0.0, width: f64::INFINITY };
tri_patch[seed] = id;
let mut stack = vec![seed as u32];
while let Some(t) = stack.pop() {
patch.triangles.push(t);
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
let n = neighbors.get(t as usize).copied().unwrap_or([u32::MAX; 3]);
for k in 0..3 {
let (a, b) = (tri[k], tri[(k + 1) % 3]);
if edge_set.contains_key(&(a.min(b), a.max(b))) {
continue;
}
if let Some(slot) = tri_patch.get_mut(n[k] as usize)
&& *slot == u32::MAX
{
*slot = id;
stack.push(n[k]);
}
}
}
patch.triangles.sort_unstable();
for &t in &patch.triangles {
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
patch.area += 0.5 * norm(cross(sub(xyz(tri[1]), xyz(tri[0])), sub(xyz(tri[2]), xyz(tri[0]))));
patch.faces.push(surface.tri_face().get(t as usize).copied().unwrap_or(0));
}
patch.faces.sort_unstable();
patch.faces.dedup();
let mut perimeter = 0.0;
for &t in &patch.triangles {
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
for k in 0..3 {
let (a, b) = (tri[k], tri[(k + 1) % 3]);
if edge_set.contains_key(&(a.min(b), a.max(b))) {
perimeter += norm(sub(xyz(b), xyz(a)));
}
}
}
if perimeter > 0.0 {
patch.width = 2.0 * patch.area / perimeter;
}
patches.push(patch);
}
// Curves: chains from corners, then closed loops.
let mut used: BTreeMap<(u32, u32), bool> = edge_set.keys().map(|k| (*k, false)).collect();
let is_corner = |v: u32| corners.binary_search(&v).is_ok();
let mut curves: Vec<Curve> = Vec::new();
let trace = |start: u32, next: u32, used: &mut BTreeMap<(u32, u32), bool>, curves: &mut Vec<Curve>| {
let mut verts = vec![start];
let (mut prev, mut cur) = (start, next);
loop {
let key = (prev.min(cur), prev.max(cur));
if let Some(u) = used.get_mut(&key) {
*u = true;
}
verts.push(cur);
if cur == start || is_corner(cur) || verts.len() > edge_set.len() + 2 {
break;
}
let nxt = adj.get(&cur).and_then(|n| n.iter().copied().find(|&w| w != prev && used.get(&(cur.min(w), cur.max(w))) == Some(&false)));
match nxt {
Some(w) => {
prev = cur;
cur = w;
}
None => break,
}
}
let closed = verts.first() == verts.last() && verts.len() > 2;
let mut ps: Vec<u32> = Vec::new();
for w in verts.windows(2) {
if let Some(t) = edge_set.get(&(w[0].min(w[1]), w[0].max(w[1]))) {
ps.extend(t.iter().filter_map(|&t| tri_patch.get(t as usize).copied()));
}
}
ps.sort_unstable();
ps.dedup();
curves.push(Curve { vertices: verts, closed, patches: ps });
};
for &c in &corners {
let ns = adj.get(&c).cloned().unwrap_or_default();
for n in ns {
if used.get(&(c.min(n), c.max(n))) == Some(&false) {
trace(c, n, &mut used, &mut curves);
}
}
}
let keys: Vec<(u32, u32)> = used.keys().copied().collect();
for k in keys {
if used.get(&k) == Some(&false) {
trace(k.0, k.1, &mut used, &mut curves);
}
}
Ok(Geometry { surface, tree, boxes, normals, tri_patch, patches, curves, corners, bounds, far })
}
pub fn xyz(&self, v: u32) -> P {
self.surface.points().get(v as usize).copied().unwrap_or([0.0; 3])
}
/// The centroid of triangle `t`.
pub fn triangle_centre(&self, t: u32) -> P {
let [a, b, c] = self.triangle(t);
[0, 1, 2].map(|k| (a[k] + b[k] + c[k]) / 3.0)
}
pub fn triangle(&self, t: u32) -> [P; 3] {
let tri = self.surface.triangles().get(t as usize).copied().unwrap_or([0; 3]);
tri.map(|v| self.xyz(v))
}
/// The closest boundary point to `p` among triangles that pass `keep`: (triangle, point,
/// squared distance).
pub fn nearest(&self, p: P, keep: impl Fn(u32) -> bool) -> Option<(u32, P, f64)> {
let mut best: Option<(u32, P, f64)> = None;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
let bd = node.bounds.dist2(p);
if best.is_some_and(|b| bd >= b.2) {
continue;
}
match node.children {
Some(l) => {
let (dl, dr) = (
self.tree.nodes.get(l).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
self.tree.nodes.get(l + 1).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
);
if dl <= dr {
stack.extend([l + 1, l]);
} else {
stack.extend([l, l + 1]);
}
}
None => {
for &t in self.tree.leaf(node) {
if !keep(t) {
continue;
}
let [a, b, c] = self.triangle(t);
let q = closest_on_triangle(p, a, b, c);
let d = dist2(p, q);
if best.is_none_or(|x| d < x.2 || (d == x.2 && t < x.0)) {
best = Some((t, q, d));
}
}
}
}
}
best
}
/// The triangles whose boxes overlap `b`, ascending.
pub fn overlapping(&self, b: &Aabb) -> Vec<u32> {
let mut out = Vec::new();
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.overlaps(b) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => out.extend(self.tree.leaf(node).iter().copied().filter(|&t| self.boxes.get(t as usize).is_some_and(|x| x.overlaps(b)))),
}
}
out.sort_unstable();
out
}
/// The first triangle hit by the ray from `p` along `d` (unit) within `max`, with its
/// distance (floating point; used to measure, never to decide).
pub fn ray(&self, p: P, d: P, max: f64, skip: u32) -> Option<(u32, f64)> {
let q = [0, 1, 2].map(|k| p[k] + d[k] * max);
let mut best: Option<(u32, f64)> = None;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.meets_segment(p, q) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => {
for &t in self.tree.leaf(node) {
if t == skip {
continue;
}
let [a, b, c] = self.triangle(t);
if let Some(s) = ray_triangle(p, d, a, b, c)
&& s > 0.0
&& s <= max
&& best.is_none_or(|x| s < x.1)
{
best = Some((t, s));
}
}
}
}
}
best
}
/// Whether `p` is strictly inside the solid, decided exactly: the parity of the boundary
/// crossings of a segment from `p` to a far point, tried along fixed directions until one
/// meets no edge or vertex and does not start on the boundary. `None` when every direction is
/// degenerate (p lies on the boundary) or `p` is not admissible.
pub fn inside(&self, p: P) -> Option<bool> {
const DIRS: [P; 7] = [
[0.5773, 0.6123, 0.5401],
[-0.6301, 0.4372, 0.6417],
[0.3989, -0.7068, 0.5839],
[0.7211, 0.4109, -0.5577],
[-0.4402, -0.6005, -0.6675],
[0.1351, 0.9013, -0.4116],
[-0.8803, 0.1237, 0.4583],
];
let pp = Point3::new(snap_to_domain(p[0]), snap_to_domain(p[1]), snap_to_domain(p[2])).ok()?;
'dirs: for d in DIRS {
let q = [0, 1, 2].map(|k| snap_to_domain(p[k] + d[k] * self.far));
let Ok(qq) = Point3::new(q[0], q[1], q[2]) else { continue };
let mut parity = false;
let mut stack = vec![0usize];
while let Some(n) = stack.pop() {
let Some(node) = self.tree.nodes.get(n) else { continue };
if !node.bounds.meets_segment(p, q) {
continue;
}
match node.children {
Some(l) => stack.extend([l, l + 1]),
None => {
for &t in self.tree.leaf(node) {
match self.crossing(&pp, &qq, t) {
Some(true) => parity = !parity,
Some(false) => {}
None => continue 'dirs,
}
}
}
}
}
return Some(parity);
}
None
}
/// Whether segment `pq` crosses triangle `t` properly (`Some(true)`), misses it
/// (`Some(false)`), or touches it degenerately (`None`): exact.
fn crossing(&self, p: &Point3, q: &Point3, t: u32) -> Option<bool> {
let tri = self.surface.triangles().get(t as usize)?;
let a = self.surface.point3(tri[0])?;
let b = self.surface.point3(tri[1])?;
let c = self.surface.point3(tri[2])?;
let (sp, sq) = (orient3d(&a, &b, &c, p), orient3d(&a, &b, &c, q));
if sq.is_zero() {
// The far end is outside the boundary's box, so never on a triangle; a segment in
// the plane is ambiguous.
return None;
}
if sp.is_zero() {
// p in the triangle's plane: the segment leaves the plane at p, so it touches the
// triangle only if p lies in it.
let axis = [Axis::Z, Axis::X, Axis::Y].into_iter().find(|&ax| !orient2d_projected(&a, &b, &c, ax).is_zero())?;
let o = orient2d_projected(&a, &b, &c, axis);
let inside = [(&a, &b), (&b, &c), (&c, &a)].into_iter().all(|(u, v)| orient2d_projected(u, v, p, axis) != -o);
return if inside { None } else { Some(false) };
}
if sp == sq {
return Some(false);
}
let s = [orient3d(p, q, &a, &b), orient3d(p, q, &b, &c), orient3d(p, q, &c, &a)];
if s.iter().any(|x| x.is_zero()) {
let nz: Vec<Sign> = s.iter().copied().filter(|x| !x.is_zero()).collect();
return if nz.windows(2).all(|w| w[0] == w[1]) { None } else { Some(false) };
}
Some(s[0] == s[1] && s[1] == s[2])
}
/// The closest point of curve `c` to `p`: (point, segment index, parameter on it).
pub fn nearest_on_curve(&self, c: u32, p: P) -> Option<(P, usize, f64)> {
let curve = self.curves.get(c as usize)?;
let mut best: Option<(P, usize, f64, f64)> = None;
for (i, w) in curve.vertices.windows(2).enumerate() {
let (q, t) = closest_on_segment(p, self.xyz(w[0]), self.xyz(w[1]));
let d = dist2(p, q);
if best.is_none_or(|b| d < b.3) {
best = Some((q, i, t, d));
}
}
best.map(|(q, i, t, _)| (q, i, t))
}
}
/// Möller–Trumbore ray–triangle distance (floating point).
fn ray_triangle(o: P, d: P, a: P, b: P, c: P) -> Option<f64> {
let (e1, e2) = (sub(b, a), sub(c, a));
let h = cross(d, e2);
let det = dot(e1, h);
if det.abs() < 1e-300 {
return None;
}
let inv = 1.0 / det;
let s = sub(o, a);
let u = inv * dot(s, h);
if !(-1e-12..=1.0 + 1e-12).contains(&u) {
return None;
}
let q = cross(s, e1);
let v = inv * dot(d, q);
if v < -1e-12 || u + v > 1.0 + 1e-12 {
return None;
}
Some(inv * dot(e2, q))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::surface::SurfaceOptions;
/// A closed axis-aligned box surface, one B-rep face per side.
pub(crate) fn box_surface(lo: P, hi: P) -> BoundarySurface {
let c = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }];
let pts: Vec<P> = (0..8).map(c).collect();
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut tris = Vec::new();
let mut face = Vec::new();
for (f, q) in quads.iter().enumerate() {
tris.push([q[0], q[1], q[2]]);
tris.push([q[0], q[2], q[3]]);
face.extend([f as u32; 2]);
}
BoundarySurface::new(&pts, &tris, &face, &SurfaceOptions::default()).unwrap()
}
#[test]
fn box_feature_graph_inside_and_nearest() {
let s = box_surface([0.0; 3], [2.0, 3.0, 4.0]);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
assert_eq!(g.corners.len(), 8);
assert_eq!(g.curves.len(), 12);
assert_eq!(g.patches.len(), 6);
assert!(g.curves.iter().all(|c| c.vertices.len() == 2 && c.patches.len() == 2 && !c.closed));
assert_eq!(g.inside([1.0, 1.5, 2.0]), Some(true));
assert_eq!(g.inside([3.0, 1.5, 2.0]), Some(false));
assert_eq!(g.inside([1.0, 1.5, 0.0]), None, "on the boundary");
assert_eq!(g.inside([0.0, 0.0, 0.0]), None, "a corner");
let (_, q, d) = g.nearest([1.0, 1.0, -1.0], |_| true).unwrap();
assert!((d - 1.0).abs() < 1e-12 && q[2] == 0.0);
let top = g.tri_patch[2];
let (_, q, _) = g.nearest([1.0, 1.0, -1.0], |t| g.tri_patch[t as usize] == top).unwrap();
assert_eq!(q[2], 4.0, "restricted to the top patch");
let (t, s) = g.ray([1.0, 1.5, 2.0], [0.0, 0.0, 1.0], 10.0, u32::MAX).unwrap();
assert!((s - 2.0).abs() < 1e-12 && g.tri_patch[t as usize] == top);
}
}
+376
View File
@@ -0,0 +1,376 @@
//! The adaptive grid: a box of root cubes, each refined as a 27-tree (a leaf splits into
//! 3 × 3 × 3), strongly balanced and closed under the template patterns, then cut into a
//! conforming hexahedral mesh by [`super::templates`].
//!
//! # Balance and closure
//! *Strong balance*: leaves that share a vertex differ by at most one level, so a leaf's finer
//! neighbours are exactly one level finer and a leaf next to a coarser one never has a finer one
//! on that side. A vertex of a leaf at level `L` is *marked* when a finer leaf touches it, that
//! is, when one of the eight level-`L` cells around the vertex is split. *Closure*: a leaf whose
//! marks fit no template (two diagonal corners of a face, three of a face, …) is split too.
//! Balancing and closing repeat until neither splits anything; both only split, so this ends,
//! and every round runs over the leaves in sorted order, so the result is the same on every run.
//!
//! # Coordinates
//! Everything is integer. A cell at level `L` has index `i` (per axis, in units of its own edge)
//! and spans `6 · 3^(D − L)` lattice units, `D` the deepest level allowed; template vertices are
//! in sixths of a leaf, so every vertex is a lattice point and vertices shared by neighbours are
//! found by exact lookup.
use std::collections::HashMap;
use super::templates::{self, Pattern, Sym};
use crate::{Error, Result};
/// A cell: its level and index (in units of its edge at that level).
pub(crate) type Key = (u8, [i64; 3]);
/// The deepest level a tree may have (`3^12` lattice steps per root cell edge, × 6).
pub(crate) const MAX_LEVEL: u8 = 12;
fn pow3(n: u8) -> i64 {
3i64.pow(u32::from(n))
}
/// What covers a cell-sized region at some level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cover {
/// A leaf (at that level or coarser).
Leaf(Key),
/// The region is split at that level (finer leaves inside).
Split,
/// Outside the root box.
Outside,
}
/// The tree. `nodes` maps every cell that exists to whether it is a leaf.
#[derive(Debug, Clone)]
pub(crate) struct Tree {
/// Root cubes per axis.
pub roots: [i64; 3],
/// The deepest level allowed.
pub depth: u8,
nodes: HashMap<Key, bool>,
leaves: usize,
max_leaves: usize,
}
/// The sort order of [`Tree::sorted_leaves`]: level, then z, y, x.
fn order(k: &Key) -> (u8, i64, i64, i64) {
(k.0, k.1[2], k.1[1], k.1[0])
}
/// The 26 neighbour offsets, in a fixed order.
fn offsets() -> impl Iterator<Item = [i64; 3]> {
(0..27).map(|k| [k % 3 - 1, (k / 3) % 3 - 1, k / 9 - 1]).filter(|d| *d != [0, 0, 0])
}
impl Tree {
/// One leaf per root cube.
pub fn new(roots: [i64; 3], depth: u8, max_leaves: usize) -> Result<Tree> {
if roots.iter().any(|&n| n < 1) || depth > MAX_LEVEL {
return Err(Error::Invalid(format!("grid of {roots:?} root cells to depth {depth}")));
}
let count = roots.iter().try_fold(1usize, |a, &n| usize::try_from(n).ok().and_then(|n| a.checked_mul(n)));
let count = count.filter(|&c| c <= max_leaves).ok_or_else(|| Error::Limit(format!("{roots:?} root cells exceed {max_leaves} cells")))?;
let mut nodes = HashMap::with_capacity(count);
for z in 0..roots[2] {
for y in 0..roots[1] {
for x in 0..roots[0] {
nodes.insert((0, [x, y, z]), true);
}
}
}
Ok(Tree { roots, depth, nodes, leaves: count, max_leaves })
}
pub fn leaf_count(&self) -> usize {
self.leaves
}
/// Whether `key` is a leaf.
pub fn is_leaf(&self, key: &Key) -> bool {
self.nodes.get(key).copied().unwrap_or(false)
}
fn inside(&self, (level, i): Key) -> bool {
let n = pow3(level);
(0..3).all(|k| i[k] >= 0 && i[k] < self.roots[k] * n)
}
/// What covers the level-`L` cell `key`.
fn cover(&self, key: Key) -> Cover {
if !self.inside(key) {
return Cover::Outside;
}
let (level, i) = key;
for l in (0..=level).rev() {
let s = pow3(level - l);
let k = (l, [i[0].div_euclid(s), i[1].div_euclid(s), i[2].div_euclid(s)]);
match self.nodes.get(&k) {
Some(true) => return Cover::Leaf(k),
Some(false) => return Cover::Split,
None => {}
}
}
Cover::Outside
}
/// Split a leaf into its 27 children.
pub fn split(&mut self, key: Key) -> Result<()> {
let level = key.0;
if !self.is_leaf(&key) {
return Ok(());
}
if level >= self.depth {
return Err(Error::Limit(format!("refinement needs more than {} levels", self.depth)));
}
if self.leaves + 26 > self.max_leaves {
return Err(Error::Limit(format!("the grid needs more than {} cells", self.max_leaves)));
}
self.nodes.insert(key, false);
for child in Self::children(key) {
self.nodes.insert(child, true);
}
self.leaves += 26;
Ok(())
}
/// The leaves in sorted order (level, then z, y, x).
pub fn sorted_leaves(&self) -> Vec<Key> {
let mut v: Vec<Key> = self.nodes.iter().filter(|(_, leaf)| **leaf).map(|(k, _)| *k).collect();
v.sort_by_key(order);
v
}
/// The children of a split cell.
pub fn children(key: Key) -> impl Iterator<Item = Key> {
let (level, i) = key;
(0..27).map(move |k| (level + 1, [3 * i[0] + k % 3, 3 * i[1] + (k / 3) % 3, 3 * i[2] + k / 9]))
}
/// Split until leaves sharing a vertex differ by at most one level, checking only the leaves
/// `work` (in sorted order) and the children of the cells it splits: a split only makes
/// neighbours finer, so only new leaves can be too fine for a neighbour, and the result is
/// the unique coarsest balanced refinement whatever the order. Every split cell goes to `log`.
fn balance_from(&mut self, mut work: Vec<Key>, log: &mut Vec<Key>) -> Result<()> {
work.reverse();
while let Some(key) = work.pop() {
if !self.is_leaf(&key) || key.0 < 2 {
continue;
}
let (level, i) = key;
for d in offsets() {
if let Cover::Leaf(k) = self.cover((level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]]))
&& k.0 + 1 < level
{
self.split(k)?;
log.push(k);
let mut kids: Vec<Key> = Self::children(k).collect();
kids.reverse();
work.extend(kids);
work.push(key);
break;
}
}
}
Ok(())
}
/// The leaves whose marks a split of `key` may change: its children and the leaves beside it
/// on its level.
fn touched_by(&self, key: Key, out: &mut Vec<Key>) {
out.extend(Self::children(key));
let (level, i) = key;
out.extend(offsets().map(|d| (level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]])).filter(|k| self.is_leaf(k)));
}
/// The marked corners of a leaf (bit `c` for corner `c`: bit 0 x, bit 1 y, bit 2 z).
pub fn marks(&self, (level, i): Key) -> u8 {
let mut mask = 0u8;
for c in 0..8usize {
let o = [(c & 1) as i64, ((c >> 1) & 1) as i64, ((c >> 2) & 1) as i64];
let split = (0..8usize).any(|e| {
let n = [i[0] + o[0] - 1 + (e & 1) as i64, i[1] + o[1] - 1 + ((e >> 1) & 1) as i64, i[2] + o[2] - 1 + ((e >> 2) & 1) as i64];
self.nodes.get(&(level, n)) == Some(&false)
});
if split {
mask |= 1 << c;
}
}
mask
}
/// Balance, then split every leaf whose marks fit no template (and every leaf marked at all
/// eight corners, whose template is the split), until nothing changes. `max_rounds` bounds the
/// rounds. Each round re-checks only the leaves the previous splits can have changed, so a
/// round costs what it splits, not the size of the tree.
pub fn close(&mut self, max_rounds: usize, skip: &dyn Fn(Key) -> bool) -> Result<()> {
let mut fresh = self.sorted_leaves();
let mut touched = fresh.clone();
for _ in 0..max_rounds {
let mut log = Vec::new();
self.balance_from(std::mem::take(&mut fresh), &mut log)?;
for &k in &log {
self.touched_by(k, &mut touched);
}
touched.retain(|k| self.is_leaf(k));
touched.sort_by_key(order);
touched.dedup();
let bad: Vec<Key> = touched
.drain(..)
.filter(|&k| !skip(k))
.filter(|&k| !matches!(templates::classify(self.marks(k)), Some(Pattern::None | Pattern::Corner | Pattern::Edge | Pattern::Face)))
.collect();
if bad.is_empty() {
return Ok(());
}
for &k in &bad {
self.split(k)?;
}
for &k in &bad {
fresh.extend(Self::children(k));
self.touched_by(k, &mut touched);
}
fresh.sort_by_key(order);
}
Err(Error::Limit(format!("grid closure did not settle in {max_rounds} rounds")))
}
/// Lattice units per edge of a level-`L` cell.
pub fn span(&self, level: u8) -> i64 {
6 * pow3(self.depth - level)
}
/// Cut every leaf by its template into a conforming mesh on the integer lattice. Fails if a
/// leaf's marks fit no template (call [`Tree::close`] first).
pub fn mesh(&self, skip: &dyn Fn(Key) -> bool) -> Result<LatticeMesh> {
let syms: Vec<Sym> = templates::symmetries();
let canon: Vec<(Pattern, Vec<templates::Hex>)> =
[Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face].into_iter().map(|p| (p, templates::template(p))).collect();
let mut out = LatticeMesh::default();
let mut index: HashMap<[i64; 3], u32> = HashMap::new();
for key in self.sorted_leaves() {
if skip(key) {
continue;
}
let mask = self.marks(key);
let pattern = templates::classify(mask).filter(|p| *p != Pattern::Full);
let (pattern, sym) = pattern
.and_then(|p| templates::orientation(&syms, p, mask).map(|s| (p, s)))
.ok_or_else(|| Error::Failed(format!("grid cell {key:?} has marks {mask:#010b} that fit no template")))?;
let hexes = canon.iter().find(|(p, _)| *p == pattern).map(|(_, h)| h.as_slice()).unwrap_or(&[]);
let (level, i) = key;
let unit = pow3(self.depth - level);
for h in hexes {
let mut g = h.map(|p| {
let q = sym.apply(p);
[0, 1, 2].map(|k| (6 * i[k] + q[k]) * unit)
});
if templates::corner0_orientation(&g) < 0 {
g = templates::reversed(g);
}
let mut ids = [0u32; 8];
for (id, p) in ids.iter_mut().zip(g) {
*id = match index.get(&p) {
Some(&v) => v,
None => {
let v = u32::try_from(out.points.len()).map_err(|_| Error::Limit("too many grid points".into()))?;
index.insert(p, v);
out.points.push(p);
v
}
};
}
out.hexes.push(ids);
out.level.push(level);
}
}
Ok(out)
}
}
/// A conforming hexahedral mesh on the integer lattice.
#[derive(Debug, Clone, Default)]
pub(crate) struct LatticeMesh {
pub points: Vec<[i64; 3]>,
pub hexes: Vec<[u32; 8]>,
/// The tree level of the leaf each hexahedron came from.
pub level: Vec<u8>,
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::quality::{Validity, element_validity};
use fieldcraft_model::ElementKind;
/// A tiny deterministic generator for test trees.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
}
fn check_conforming(tree: &Tree) {
let m = tree.mesh(&|_| false).unwrap();
let mut faces: HashMap<[u32; 4], usize> = HashMap::new();
let mut volume = 0.0;
for h in &m.hexes {
let xyz: Vec<[f64; 3]> = h.iter().map(|&v| m.points[v as usize].map(|c| c as f64)).collect();
let c = element_validity(ElementKind::Hex8, &xyz).unwrap();
assert_eq!(c.validity, Validity::Valid);
volume += c.volume;
for f in ElementKind::Hex8.faces() {
let mut q = [h[f[0]], h[f[1]], h[f[2]], h[f[3]]];
q.sort();
*faces.entry(q).or_default() += 1;
}
}
let side = tree.span(0) as f64;
let expected = side.powi(3) * (tree.roots[0] * tree.roots[1] * tree.roots[2]) as f64;
assert!((volume - expected).abs() < 1e-6 * expected, "volume {volume} vs {expected}");
let hi = tree.roots.map(|n| n * tree.span(0));
for (q, n) in faces {
let on_box = (0..3).any(|k| q.iter().all(|&v| m.points[v as usize][k] == 0) || q.iter().all(|&v| m.points[v as usize][k] == hi[k]));
assert_eq!(n, if on_box { 1 } else { 2 }, "face {q:?}");
}
}
#[test]
fn random_trees_close_into_conforming_valid_meshes() {
let mut rng = Lcg(7);
for case in 0..12 {
let roots = [1 + (rng.next() % 2) as i64, 1 + (rng.next() % 2) as i64, 1];
let mut tree = Tree::new(roots, 3, 1_000_000).unwrap();
for _ in 0..(1 + rng.next() % 6) {
let leaves = tree.sorted_leaves();
let k = leaves[(rng.next() as usize) % leaves.len()];
if k.0 < 3 {
tree.split(k).unwrap();
}
}
tree.close(100, &|_| false).unwrap();
for k in tree.sorted_leaves() {
assert!(templates::classify(tree.marks(k)).is_some(), "case {case}");
}
check_conforming(&tree);
}
}
#[test]
fn caps_are_errors() {
assert!(Tree::new([0, 1, 1], 2, 10).is_err());
assert!(Tree::new([10, 10, 10], 2, 999).is_err());
let mut t = Tree::new([1, 1, 1], 1, 100).unwrap();
t.split((0, [0, 0, 0])).unwrap();
assert!(t.split((1, [0, 0, 0])).is_err(), "deeper than the depth");
let mut t = Tree::new([1, 1, 1], 3, 30).unwrap();
t.split((0, [0, 0, 0])).unwrap();
assert!(t.split((1, [0, 0, 0])).is_err(), "more cells than allowed");
}
}
+282
View File
@@ -0,0 +1,282 @@
//! The topological map from the boundary's feature graph (corners, curves, patches) onto the
//! core's boundary quads, after X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
//! hexahedral meshing*, CGF 38(5), 2019, §3.3 (paper only; the authors' code has no licence and
//! is not read). Gao et al. trace the curves first and read the patches off them; here the
//! patches come first and the curves are their borders, which keeps every region on the side of
//! the part it faces:
//!
//! 1. **Label.** Each quad takes the patch its outward normal meets first (within two quad
//! sizes), or else the patch nearest its centre.
//! 2. **Clean.** A quad whose label differs from all its edge neighbours takes their majority
//! label; then every patch keeps only its largest connected group of quads, the other
//! groups joining the label they border most. Both repeat a bounded number of times.
//! 3. **Check.** The map must be a bijection: every patch has a region, and two regions border
//! each other exactly where the part has a curve between their patches.
//! 4. **Features.** Region borders are the curves (each border edge takes the curve between its
//! two patches nearest to it); a vertex where three or more regions meet is a corner and takes
//! the nearest corner of the part around those patches. Corners that are not region junctions
//! (where one curve turns sharply) go to the nearest vertex on their curve's border.
//!
//! Any failure names where the grid is too coarse for the features; the caller refines there and
//! tries again (§3.3, "Bijective map").
use std::collections::{BTreeMap, BTreeSet};
use super::geom::{Geometry, closest_on_segment, dist2};
use super::smooth::Constraint;
use super::topo::quad_edges;
use crate::vec3::{P, cross, norm, sub};
/// Rounds of label cleaning.
const CLEAN_ROUNDS: usize = 16;
/// Where and why the map failed.
#[derive(Debug, Clone)]
pub(crate) struct MapFailure {
pub at: Vec<P>,
pub why: String,
}
/// The map: a constraint per quad vertex and a patch per quad.
#[derive(Debug, Clone)]
pub(crate) struct FeatureMap {
pub role: BTreeMap<u32, Constraint>,
pub quad_patch: Vec<u32>,
}
/// Map the feature graph onto `quads` (the core's boundary, outward) over `xyz`.
pub(crate) fn map_features(g: &Geometry, quads: &[[u32; 4]], xyz: &dyn Fn(u32) -> P) -> Result<FeatureMap, MapFailure> {
let n = quads.len();
let centre = |q: usize| {
let mut c = [0.0; 3];
for &v in &quads[q] {
let p = xyz(v);
for k in 0..3 {
c[k] += 0.25 * p[k];
}
}
c
};
let edges = quad_edges(quads);
let mut nbr: Vec<Vec<u32>> = vec![Vec::new(); n];
for qs in edges.values() {
if let [a, b] = qs.as_slice() {
nbr[*a as usize].push(*b);
nbr[*b as usize].push(*a);
}
}
// 1. Label: the patch the quad faces (a ray along its outward normal), else the nearest.
let mut label: Vec<u32> = (0..n)
.map(|q| {
let c = centre(q);
let p = quads[q].map(xyz);
let nrm = cross(sub(p[2], p[0]), sub(p[3], p[1]));
let l = norm(nrm);
let reach = 2.0 * (norm(sub(p[2], p[0])) + norm(sub(p[3], p[1])));
let hit = if l > 0.0 { g.ray(c, nrm.map(|x| x / l), reach, u32::MAX).map(|(t, _)| t) } else { None };
hit.or_else(|| g.nearest(c, |_| true).map(|(t, _, _)| t)).and_then(|t| g.tri_patch.get(t as usize).copied()).unwrap_or(0)
})
.collect();
// A patch no quad faces (a narrow face the core's boundary passes by) takes the quad
// nearest to it, which the cleaning keeps.
let mut seeded: BTreeSet<usize> = BTreeSet::new();
let have: BTreeSet<u32> = label.iter().copied().collect();
for p in 0..g.patches.len() as u32 {
if have.contains(&p) {
continue;
}
let best = (0..n)
.filter(|q| !seeded.contains(q))
.filter_map(|q| g.nearest(centre(q), |t| g.tri_patch.get(t as usize) == Some(&p)).map(|x| (q, x.2)))
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
if let Some((q, _)) = best {
label[q] = p;
seeded.insert(q);
}
}
// 2. Clean.
for _ in 0..CLEAN_ROUNDS {
let mut changed = false;
for q in 0..n {
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
for &m in &nbr[q] {
*count.entry(label[m as usize]).or_default() += 1;
}
let same = count.get(&label[q]).copied().unwrap_or(0);
if same == 0
&& !nbr[q].is_empty()
&& !seeded.contains(&q)
&& let Some((&best, _)) = count.iter().filter(|(l, _)| **l != label[q]).max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
{
label[q] = best;
changed = true;
}
}
let mut comp = vec![usize::MAX; n];
let mut comps: Vec<(u32, Vec<usize>)> = Vec::new();
for s in 0..n {
if comp[s] != usize::MAX {
continue;
}
let id = comps.len();
let mut members = vec![s];
comp[s] = id;
let mut i = 0;
while i < members.len() {
let q = members[i];
i += 1;
for &m in &nbr[q] {
let m = m as usize;
if comp[m] == usize::MAX && label[m] == label[s] {
comp[m] = id;
members.push(m);
}
}
}
comps.push((label[s], members));
}
let mut largest: BTreeMap<u32, usize> = BTreeMap::new();
for (i, (l, m)) in comps.iter().enumerate() {
let e = largest.entry(*l).or_insert(i);
if comps[*e].1.len() < m.len() {
*e = i;
}
}
for (i, (l, members)) in comps.iter().enumerate() {
if largest.get(l) == Some(&i) {
continue;
}
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
for &q in members {
for &m in &nbr[q] {
if comp[m as usize] != i {
*count.entry(label[m as usize]).or_default() += 1;
}
}
}
if let Some((&best, _)) = count.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0))) {
for &q in members {
label[q] = best;
}
changed = true;
}
}
if !changed {
break;
}
}
// 3. Check: every patch has a region; borders match curves.
let present: BTreeSet<u32> = label.iter().copied().collect();
for (p, patch) in g.patches.iter().enumerate() {
if !present.contains(&(p as u32)) {
let at: Vec<P> = patch.triangles.iter().take(64).map(|&t| g.triangle_centre(t)).collect();
return Err(MapFailure { at, why: format!("patch {p} (B-rep faces {:?}) has no region", patch.faces) });
}
}
let mut curves_between: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
for (i, c) in g.curves.iter().enumerate() {
match c.patches.as_slice() {
[a, b] => curves_between.entry((*a, *b)).or_default().push(i as u32),
[a] => curves_between.entry((*a, *a)).or_default().push(i as u32),
_ => {}
}
}
let mut role: BTreeMap<u32, Constraint> = BTreeMap::new();
let mut border_pairs: BTreeSet<(u32, u32)> = BTreeSet::new();
let mut vertex_labels: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
for (q, quad) in quads.iter().enumerate() {
for &v in quad {
vertex_labels.entry(v).or_default().insert(label[q]);
}
}
for ((a, b), qs) in &edges {
let (q0, q1) = match qs.as_slice() {
[x, y] => (*x as usize, *y as usize),
_ => continue,
};
let (la, lb) = (label[q0].min(label[q1]), label[q0].max(label[q1]));
if la == lb {
continue;
}
let Some(cs) = curves_between.get(&(la, lb)) else {
return Err(MapFailure { at: vec![xyz(*a), xyz(*b)], why: format!("regions of patches {la} and {lb} meet where the part has no curve") });
};
border_pairs.insert((la, lb));
let mid = [0, 1, 2].map(|k| 0.5 * (xyz(*a)[k] + xyz(*b)[k]));
let c = nearest_curve(g, cs, mid);
for v in [*a, *b] {
role.entry(v).or_insert(Constraint::Curve(c));
}
}
for (pair, cs) in &curves_between {
if pair.0 != pair.1 && !border_pairs.contains(pair) {
let at: Vec<P> = cs.iter().flat_map(|&c| g.curves[c as usize].vertices.iter().map(|&v| g.xyz(v)).collect::<Vec<_>>()).collect();
return Err(MapFailure { at, why: format!("the regions of patches {} and {} do not meet along their curve", pair.0, pair.1) });
}
}
// 4. Corners.
let mut taken: BTreeSet<u32> = BTreeSet::new();
let junctions: Vec<u32> = vertex_labels.iter().filter(|(_, l)| l.len() >= 3).map(|(v, _)| *v).collect();
for &v in &junctions {
let p = xyz(v);
let labels = &vertex_labels[&v];
let best = g
.corners
.iter()
.copied()
.filter(|c| !taken.contains(c) && corner_patches(g, *c).is_superset(labels))
.min_by(|&a, &b| dist2(g.xyz(a), p).total_cmp(&dist2(g.xyz(b), p)).then(a.cmp(&b)));
match best {
Some(c) => {
taken.insert(c);
role.insert(v, Constraint::Corner(c));
}
None => return Err(MapFailure { at: vec![p], why: format!("regions {labels:?} meet where the part has no corner") }),
}
}
for &c in &g.corners {
if taken.contains(&c) {
continue;
}
let p = g.xyz(c);
let cp = corner_patches(g, c);
let best = role
.iter()
.filter(|(_, r)| matches!(r, Constraint::Curve(_)))
.map(|(v, _)| *v)
.filter(|v| vertex_labels.get(v).is_some_and(|l| l.is_subset(&cp)))
.min_by(|&a, &b| dist2(xyz(a), p).total_cmp(&dist2(xyz(b), p)).then(a.cmp(&b)));
match best {
Some(v) => {
role.insert(v, Constraint::Corner(c));
}
None => return Err(MapFailure { at: vec![p], why: format!("the feature corner at {p:?} has no vertex") }),
}
}
for (q, quad) in quads.iter().enumerate() {
for &v in quad {
role.entry(v).or_insert(Constraint::Patch(label[q]));
}
}
Ok(FeatureMap { role, quad_patch: label })
}
/// The patches around a corner of the part (on either side of the curves that end there).
fn corner_patches(g: &Geometry, c: u32) -> BTreeSet<u32> {
g.curves.iter().filter(|cv| cv.vertices.first() == Some(&c) || cv.vertices.last() == Some(&c)).flat_map(|cv| cv.patches.iter().copied()).collect()
}
/// The curve of `cs` nearest to `p`.
fn nearest_curve(g: &Geometry, cs: &[u32], p: P) -> u32 {
cs.iter().copied().min_by(|&a, &b| curve_distance(g, a, p).total_cmp(&curve_distance(g, b, p)).then(a.cmp(&b))).unwrap_or(0)
}
fn curve_distance(g: &Geometry, c: u32, p: P) -> f64 {
g.curves.get(c as usize).map_or(f64::INFINITY, |cv| {
cv.vertices.windows(2).map(|w| dist2(p, closest_on_segment(p, g.xyz(w[0]), g.xyz(w[1])).0)).fold(f64::INFINITY, f64::min)
})
}
+801
View File
@@ -0,0 +1,801 @@
//! Unstructured all-hexahedral meshing of any closed boundary: an adaptive grid cut into
//! conforming hexahedra, a core of grid elements inside the part, two boundary layers that carry
//! the part's faces, sharp curves and corners, and an optimisation that fits them to the
//! geometry while keeping every element valid.
//!
//! # Pipeline
//! 1. **Frame.** The grid is aligned with the part: the normals of its two largest planar
//! patches when they are orthogonal and cover more area than the coordinate axes do, the
//! coordinate axes otherwise (octree methods are orientation dependent; N. Pietroni et al.,
//! *Hex-mesh generation and processing: a survey*, ACM TOG 42(2), 2022, §4.6).
//! 2. **Grid** ([`grid`]). Root cubes of the target size over the part's box, refined as a
//! 27-tree where a B-rep face curves (its normals turn by more than
//! [`OctreeOptions::curvature_angle`] inside a cell), where the part is thin (fewer than
//! [`OctreeOptions::thickness_cells`] cells across, measured along the inward normal of each
//! boundary triangle) and where a patch is narrow, then strongly balanced and closed under the
//! transition templates.
//! 3. **Conforming hexahedra** ([`templates`]): the 3-refinement node-marking templates
//! (R. Schneiders, 1996; Y. Ito, A. M. Shih, B. K. Soni, IJNME 77(13), 2009), so every grid
//! leaf becomes 1, 4, 11, 22 or 27 hexahedra and the grid is conforming by construction.
//! 4. **Core.** Elements whose eight corners are inside the part (exact ray parity on the
//! boundary triangles) and at least [`OctreeOptions::core_offset`] of their cell size from it.
//! Elements are dropped until the core's boundary is a 2-manifold and every boundary vertex can
//! move outward (its quads' normals lie in one half-space: the buffer rule of H. Tong,
//! E. Halilaj, Y. J. Zhang, *HybridOctree_Hex*, arXiv 2401.05984, 2024); its components and
//! genus must match the part's shells.
//! 5. **Feature map** ([`map`]): the part's patches, sharp curves and corners mapped onto the
//! core's boundary quads (X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
//! hexahedral meshing*, CGF 38(5), 2019, §3.3), patches first.
//! 6. **Padding and pillowing** ([`topo`]): one layer of hexahedra on the core's boundary (so no
//! element keeps two faces on the boundary), then each patch's layer is pillowed, which inserts
//! a ring of quads along every sharp curve on both sides: a curve then runs between quads that
//! each have one edge on it, so straight and curved features are captured without degenerate
//! quads (Gao et al. 2019, §3.4, Fig. 9).
//! 7. **Fit** ([`smooth`]): the foldover-free energy of V. Garanzha et al., *Foldover-free maps
//! in 50 lines of code*, ACM TOG 40(4), 2021, plus a fitting term that draws every boundary
//! vertex onto its corner, curve or patch with a growing weight (Gao et al. 2019, §3.5); the
//! barrier keeps every element valid while the boundary moves. Each boundary vertex then goes
//! exactly onto its feature where its elements stay valid; where they would not (a blade, a
//! near-tangent contact) the mesh stays off by [`OctreeReport::boundary_distance`].
//! 8. **Gate.** The worst elements are improved by compass search, then every element is proven
//! valid by the certified Jacobian check (Bernstein bounds, [`crate::quality`]; corner
//! Jacobians alone do not prove a trilinear hexahedron valid, J. Marschner et al., CGF 39(5),
//! 2020); elements that are not are repaired locally first. A failure in 4–8 names where the
//! grid is too coarse; the leaves there are split once more and the mesh is rebuilt, at most
//! [`OctreeOptions::attempts`] times, and a rebuild may not grow the grid more than
//! [`RETRY_GROWTH`] times (Gao et al. 2019, §3.3).
//!
//! # Why 3-refinement templates
//! The dual schemes of M. Livesu, L. Pitzalis, G. Cherchi, *Optimal dual schemes for adaptive
//! grid based hexmeshing* (ACM TOG 41(2), 2022) give a simpler singular structure, but their
//! installation depends on a paired grid whose transition vertices the reference pipeline
//! (L. Pitzalis et al., *Generalized adaptive refinement for grid-based hexahedral meshing*, ACM
//! TOG 40(6), 2021) selects with an integer linear program, and on heuristics that are retried
//! with other parameters when they fail. The node-marking templates need only the balance and
//! closure rules and are proven conforming by an exhaustive test. Their limit is the one
//! Schneiders states: they close convex refined regions only, so a leaf next to a concave corner
//! of a finer region is split, and the split spreads to the region's orthogonal hull (H. Tong,
//! Y. J. Zhang, *MCHex*, arXiv 2511.02064, 2025, meet the same limit with the same templates).
//! Curvature therefore refines only [`OctreeOptions::curvature_levels`] levels; deeper levels
//! follow thickness and narrow faces, whose refined regions are mostly convex. Schneiders'
//! modified edge rule (every edge with a marked end split in three) or the dual schemes are the
//! upgrade path.
//!
//! # Determinism and bounds
//! The same boundary and options give the same mesh: grid cells are processed in sorted order,
//! maps are only looked up, the optimiser sweeps vertices in index order. Grid cells, elements,
//! refinement depth, closure rounds, optimiser sweeps and attempts are capped.
mod geom;
mod grid;
mod map;
mod smooth;
mod templates;
mod topo;
#[cfg(test)]
mod tests;
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use fieldcraft_model::ElementKind;
pub use crate::hex::BoundaryFace;
use crate::hex::face_flux;
use crate::quality::{GateOptions, Metric, Validity, element_quality, validity_gate_checks};
use crate::size::{SizeField, checked_size};
use crate::surface::BoundarySurface;
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
use geom::{Aabb, Geometry};
use grid::Tree;
use smooth::{Constraint, Optimizer};
/// Options and caps of [`octree_hex_mesh`].
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeOptions {
/// Refinement levels below the root cells (each one a third of the size), at most 8.
pub max_depth: u8,
/// The most grid cells and the most output elements.
pub max_elements: usize,
/// A cell is split where the normals of one B-rep face inside it turn by more than this
/// (degrees).
pub curvature_angle: f64,
/// Levels below the root that curvature may refine (see the module docs).
pub curvature_levels: u8,
/// A cell is split where the part is less than this many cells thick.
pub thickness_cells: f64,
/// Core elements keep this fraction of their cell size from the boundary.
pub core_offset: f64,
/// Optimiser sweeps over all vertices.
pub max_sweeps: usize,
/// Builds, counting the rebuilds after refining where a stage failed (1 = no retry).
pub attempts: usize,
/// Boundary edges whose faces meet at more than this angle (degrees between normals) are
/// sharp features the mesh captures with its own edges; smoother face boundaries are not.
pub sharp_angle: f64,
/// Edges whose normals turn by more than this (degrees: thin blades, near-tangent contacts)
/// are not captured; the mesh rounds them.
pub blade_angle: f64,
}
impl Default for OctreeOptions {
fn default() -> Self {
OctreeOptions {
max_depth: 2,
max_elements: 5_000_000,
curvature_angle: 25.0,
curvature_levels: 1,
thickness_cells: 3.0,
core_offset: 0.3,
max_sweeps: 300,
attempts: 4,
sharp_angle: 30.0,
blade_angle: 160.0,
}
}
}
/// What [`octree_hex_mesh`] did.
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeReport {
/// Builds run (1 + rebuilds after a failure).
pub attempts: usize,
/// Grid leaves of the last build.
pub grid_cells: usize,
/// Core elements (from the grid) and boundary-layer elements.
pub core_elements: usize,
pub layer_elements: usize,
/// The grid's axes.
pub frame: [[f64; 3]; 3],
/// Sharp corners, curves and patches of the boundary.
pub corners: usize,
pub curves: usize,
pub patches: usize,
/// Optimiser sweeps of the last build.
pub sweeps: usize,
/// Smallest and mean corner scaled Jacobian.
pub min_scaled_jacobian: f64,
pub mean_scaled_jacobian: f64,
/// Exact volume of the elements and the boundary's enclosed volume.
pub volume: f64,
pub surface_volume: f64,
/// The largest distance of a boundary vertex from its corner, curve or patch: zero when
/// every boundary vertex lies on the geometry; where the geometry is sharper than valid
/// elements can follow (blades, slits), the mesh stays this far off.
pub boundary_distance: f64,
/// Why earlier builds were refined.
pub retries: Vec<String>,
}
/// An all-hex8 mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct OctreeHexMesh {
pub nodes: Vec<[f64; 3]>,
/// Model node order; every element is proven valid.
pub hexes: Vec<[u32; 8]>,
pub boundary: Vec<BoundaryFace>,
pub report: OctreeReport,
}
/// A rebuild after a failure may have at most this many times the grid cells of the build
/// before…
pub const RETRY_GROWTH: usize = 4;
/// … or this many cells, whichever is more.
const RETRY_MIN_CELLS: usize = 250_000;
/// The fitting weights of the successive optimisation stages.
const FIT_STAGES: &[f64] = &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0];
/// A boundary vertex goes exactly onto its feature only if its elements keep at least this
/// corner scaled Jacobian.
const MIN_SNAP_QUALITY: f64 = 0.05;
/// Elements below this corner scaled Jacobian are improved by compass search.
const IMPROVE_BELOW: f64 = 0.3;
/// Closure rounds of the grid (each splits at least one leaf; the cell cap bounds them too).
const CLOSURE_ROUNDS: usize = 1000;
/// Rounds of the core clean-up (each drops at least one element).
const CORE_ROUNDS: usize = 1000;
/// A failure that refining at `at` may cure.
struct Retry {
at: Vec<P>,
why: String,
}
enum Attempt {
Done(Box<OctreeHexMesh>),
Retry(Retry),
}
/// Mesh the solid bounded by `surface` with hexahedra of about `field`'s size (the root cell
/// size is the field at the centre of the part's box; finer cells follow the part's curvature,
/// thickness and narrow faces). See the module docs.
pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options: &OctreeOptions) -> Result<OctreeHexMesh> {
check_options(options)?;
let g = Geometry::new(surface, options.sharp_angle, options.blade_angle)?;
let frame = choose_frame(&g);
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for &p in surface.points() {
let u = to_frame(&frame, p);
for k in 0..3 {
lo[k] = lo[k].min(u[k]);
hi[k] = hi[k].max(u[k]);
}
}
let centre_u = [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k]));
let h = checked_size(field, from_frame(&frame, centre_u))?;
let mut roots = [0i64; 3];
for k in 0..3 {
let n = ((hi[k] - lo[k]) / h + 1.0).ceil();
if !(n.is_finite() && n >= 1.0 && n <= options.max_elements as f64) {
return Err(Error::Limit(format!("a size of {h} needs {n} root cells along an axis")));
}
roots[k] = n as i64;
}
let origin = [0, 1, 2].map(|k| centre_u[k] - 0.5 * roots[k] as f64 * h);
let ctx = Context {
g: &g,
field,
options,
frame,
origin,
h,
roots,
thickness: triangle_thickness(&g),
outside_memo: RefCell::new(HashMap::new()),
last_leaves: Cell::new(0),
too_thin: Cell::new(f64::INFINITY),
};
let mut hints: Vec<P> = Vec::new();
let mut retries: Vec<String> = Vec::new();
for attempt in 0..options.attempts {
let cap = if attempt == 0 {
options.max_elements
} else {
options.max_elements.min(RETRY_GROWTH.saturating_mul(ctx.last_leaves.get()).max(RETRY_MIN_CELLS))
};
let built = match ctx.build(&hints, cap) {
Err(Error::Limit(why)) if attempt > 0 => {
retries.push(format!("refining for another build needs more than {cap} grid cells ({why})"));
break;
}
other => other?,
};
match built {
Attempt::Done(mut m) => {
m.report.attempts = attempt + 1;
m.report.retries = retries;
return Ok(*m);
}
Attempt::Retry(r) => {
retries.push(r.why);
hints.extend(r.at);
}
}
}
Err(Error::Failed(format!("octree hex meshing failed after {} builds: {}", retries.len(), retries.join("; "))))
}
fn check_options(o: &OctreeOptions) -> Result<()> {
let ok = o.max_depth <= 8
&& o.curvature_levels <= o.max_depth
&& o.max_elements >= 1
&& o.curvature_angle.is_finite()
&& o.curvature_angle > 0.0
&& o.thickness_cells.is_finite()
&& o.thickness_cells >= 0.0
&& o.core_offset.is_finite()
&& (0.0..1.0).contains(&o.core_offset)
&& (1..=16).contains(&o.attempts)
&& o.sharp_angle.is_finite()
&& o.sharp_angle > 0.0
&& o.blade_angle.is_finite()
&& o.blade_angle > o.sharp_angle
&& o.blade_angle <= 180.0
&& o.max_sweeps <= 100_000;
if ok { Ok(()) } else { Err(Error::Invalid(format!("octree options out of range: {o:?}"))) }
}
fn from_frame(f: &[[f64; 3]; 3], u: P) -> P {
[0, 1, 2].map(|k| u[0] * f[0][k] + u[1] * f[1][k] + u[2] * f[2][k])
}
fn to_frame(f: &[[f64; 3]; 3], p: P) -> P {
[dot(p, f[0]), dot(p, f[1]), dot(p, f[2])]
}
/// The grid axes (rows): the coordinate axes, or the normals of the two largest orthogonal planar
/// patches when more boundary area is aligned with them.
fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
let tol = 1f64.to_radians().sin();
let normal = |t: u32| g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let mut planar: Vec<(f64, usize, P)> = Vec::new();
for (i, p) in g.patches.iter().enumerate() {
let Some(&t0) = p.triangles.first() else { continue };
let n0 = normal(t0);
if p.triangles.iter().all(|&t| norm(cross(normal(t), n0)) < tol) {
planar.push((p.area, i, n0));
}
}
planar.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let Some(&(_, _, a0)) = planar.first() else { return identity };
let Some(&(_, _, a1)) = planar.iter().find(|x| dot(x.2, a0).abs() < tol) else { return identity };
let a1 = {
let v = sub(a1, a0.map(|x| x * dot(a1, a0)));
let l = norm(v);
v.map(|x| x / l)
};
let candidate = [a0, a1, cross(a0, a1)];
let score = |f: &[[f64; 3]; 3]| -> f64 {
(0..g.surface.triangles().len() as u32)
.filter(|&t| f.iter().any(|a| norm(cross(normal(t), *a)) < tol))
.map(|t| {
let [a, b, c] = g.triangle(t);
0.5 * norm(cross(sub(b, a), sub(c, a)))
})
.sum()
};
if score(&candidate) > score(&identity) * (1.0 + 1e-9) { candidate } else { identity }
}
/// The distance through the part from each boundary triangle: a ray from its centre along the
/// inward normal to the next triangle (`∞` when none).
fn triangle_thickness(g: &Geometry) -> Vec<f64> {
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
(0..g.surface.triangles().len() as u32)
.map(|t| {
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
g.ray(g.triangle_centre(t), n.map(|x| -x), far, t).map_or(f64::INFINITY, |(_, s)| s)
})
.collect()
}
fn centroid(points: &[P], hx: &[u32; 8]) -> P {
[0, 1, 2].map(|k| hx.iter().map(|&v| points.get(v as usize).map_or(0.0, |p| p[k])).sum::<f64>() / 8.0)
}
fn edge_mean(points: &[P], hx: &[u32; 8]) -> f64 {
let x = hx.map(|v| points.get(v as usize).copied().unwrap_or([0.0; 3]));
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
let m = edges.iter().map(|&(a, b)| norm(sub(x[a], x[b]))).sum::<f64>() / 12.0;
if m > 0.0 { m } else { 1.0 }
}
/// The part being meshed and the state carried from one build to the next.
struct Context<'a> {
g: &'a Geometry<'a>,
field: &'a dyn SizeField,
options: &'a OctreeOptions,
frame: [[f64; 3]; 3],
origin: P,
h: f64,
roots: [i64; 3],
thickness: Vec<f64>,
outside_memo: RefCell<HashMap<grid::Key, bool>>,
/// Grid leaves of the last build.
last_leaves: Cell<usize>,
/// The thinnest wall the deepest cells cannot cross in `thickness_cells` (∞ when none).
too_thin: Cell<f64>,
}
/// The mesh while its boundary layers are built: points, elements, their target sizes and how
/// each point may move.
struct Work {
points: Vec<P>,
hexes: Vec<[u32; 8]>,
size: Vec<f64>,
role: Vec<Constraint>,
core_count: usize,
}
impl Context<'_> {
fn edge(&self, level: u8) -> f64 {
self.h / 3f64.powi(i32::from(level))
}
/// The world box (inflated by `pad` cell edges) and the centre of a cell.
fn cell_box(&self, (level, i): grid::Key, pad: f64) -> (Aabb, P) {
let e = self.edge(level);
let mut b = Aabb::EMPTY;
for c in 0..8 {
b.add(from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + ((c >> k) & 1) as f64) * e)));
}
let centre = from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + 0.5) * e));
let p = pad * e;
(Aabb { lo: b.lo.map(|x| x - p), hi: b.hi.map(|x| x + p) }, centre)
}
fn needs_split(&self, key: grid::Key) -> Result<bool> {
let e = self.edge(key.0);
let (bx, centre) = self.cell_box(key, 0.05);
if e > checked_size(self.field, centre)? * (1.0 + 1e-9) {
return Ok(true);
}
let reach = 0.5 * 3f64.sqrt() * e * 1.05;
let cos = self.options.curvature_angle.to_radians().cos();
let mut first: BTreeMap<u32, P> = BTreeMap::new();
for t in self.g.overlapping(&bx) {
let [a, b, c] = self.g.triangle(t);
if geom::dist2(centre, geom::closest_on_triangle(centre, a, b, c)) > reach * reach {
continue;
}
let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let face = self.g.surface.tri_face().get(t as usize).copied().unwrap_or(0);
let f = *first.entry(face).or_insert(n);
if key.0 < self.options.curvature_levels && dot(f, n) < cos {
return Ok(true);
}
if let Some(&th) = self.thickness.get(t as usize)
&& th < self.options.thickness_cells * e
{
if key.0 + 1 >= self.options.max_depth && th < self.options.thickness_cells * e / 3.0 {
self.too_thin.set(self.too_thin.get().min(th));
}
return Ok(true);
}
// Narrow patches (a hole's wall, a thin rim) need cells across them.
if self.g.tri_patch.get(t as usize).and_then(|&p| self.g.patches.get(p as usize)).is_some_and(|pt| pt.width < 2.0 * e) {
return Ok(true);
}
}
Ok(false)
}
/// Whether a cell lies wholly outside the part (its centre outside and farther from the
/// boundary than its half diagonal): no core element can come from it, so its transition
/// pattern does not matter and it is not meshed. Memoised.
fn outside(&self, key: grid::Key) -> bool {
if let Some(&v) = self.outside_memo.borrow().get(&key) {
return v;
}
let (_, centre) = self.cell_box(key, 0.0);
let half = 0.5 * 3f64.sqrt() * self.edge(key.0);
let far = self.g.nearest(centre, |_| true).is_some_and(|x| x.2 > half * half * (1.0 + 1e-9));
let v = far && self.g.inside(centre) == Some(false);
self.outside_memo.borrow_mut().insert(key, v);
v
}
/// The level-`level` cells within one cell of a hint point.
fn hint_cells(&self, hints: &[P], level: u8) -> HashSet<grid::Key> {
let e = self.edge(level);
let mut out = HashSet::new();
for &p in hints {
let u = to_frame(&self.frame, p);
let i = [0, 1, 2].map(|k| ((u[k] - self.origin[k]) / e).floor());
if !i.iter().all(|x| x.is_finite() && x.abs() < 1e15) {
continue;
}
for d in 0..27i64 {
out.insert((level, [i[0] as i64 + d % 3 - 1, i[1] as i64 + (d / 3) % 3 - 1, i[2] as i64 + d / 9 - 1]));
}
}
out
}
/// The refined, balanced and closed grid; leaves near `hints` (where an earlier build
/// failed) are split once more.
fn tree(&self, hints: &[P], max_leaves: usize) -> Result<Tree> {
let mut tree = Tree::new(self.roots, self.options.max_depth, max_leaves)?;
for level in 0..self.options.max_depth {
for key in tree.sorted_leaves() {
if key.0 == level && self.needs_split(key)? {
tree.split(key)?;
}
}
}
if !hints.is_empty() {
let near: Vec<HashSet<grid::Key>> = (0..self.options.max_depth).map(|l| self.hint_cells(hints, l)).collect();
let split: Vec<grid::Key> =
tree.sorted_leaves().into_iter().filter(|k| near.get(usize::from(k.0)).is_some_and(|s| s.contains(k))).collect();
for key in split {
tree.split(key)?;
}
}
tree.close(CLOSURE_ROUNDS, &|k| self.outside(k))?;
Ok(tree)
}
fn build(&self, hints: &[P], max_leaves: usize) -> Result<Attempt> {
let g = self.g;
let tree = self.tree(hints, max_leaves)?;
self.last_leaves.set(tree.leaf_count());
let lattice = tree.mesh(&|k| self.outside(k))?;
if lattice.hexes.len() > self.options.max_elements {
return Err(Error::Limit(format!("the grid has {} elements; the limit is {}", lattice.hexes.len(), self.options.max_elements)));
}
let unit = self.h / tree.span(0) as f64;
let points: Vec<P> = lattice.points.iter().map(|q| from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + q[k] as f64 * unit))).collect();
// Core.
let dist: Vec<f64> = points.iter().map(|&p| g.nearest(p, |_| true).map_or(0.0, |x| x.2.sqrt())).collect();
let keep = self.core(&lattice, &points, &dist);
let core: Vec<usize> = (0..lattice.hexes.len()).filter(|&i| keep[i]).collect();
if core.is_empty() {
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
return Ok(Attempt::Retry(Retry { at, why: "no grid element lies inside the part".into() }));
}
let mut w = Work {
hexes: core.iter().map(|&i| lattice.hexes[i]).collect(),
size: core.iter().map(|&i| self.edge(lattice.level[i])).collect(),
role: Vec::new(),
points,
core_count: core.len(),
};
let qb = topo::boundary(&w.hexes);
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
if let Some(r) = check_topology(g, &q) {
let thin = self.too_thin.get();
if thin.is_finite() {
return Err(Error::Failed(format!(
"{}: the part has walls {thin:.3} thick, thinner than {} cells of the deepest level ({:.3}); use a smaller size, more levels, or a structured (swept) hex mesh",
r.why,
self.options.thickness_cells,
self.edge(self.options.max_depth)
)));
}
return Ok(Attempt::Retry(r));
}
// Feature map, padding and pillows.
let fmap = {
let pts = &w.points;
match map::map_features(g, &q, &|v| pts.get(v as usize).copied().unwrap_or([0.0; 3])) {
Ok(m) => m,
Err(f) => return Ok(Attempt::Retry(Retry { at: f.at, why: f.why })),
}
};
self.layers(&mut w, &qb, &fmap, &dist)?;
if w.hexes.len() > self.options.max_elements {
return Err(Error::Limit(format!("the mesh needs {} elements; the limit is {}", w.hexes.len(), self.options.max_elements)));
}
// Fit, then improve and repair.
let mut opt = Optimizer::new(g, std::mem::take(&mut w.points), &w.hexes, &w.size, w.role.clone());
let mut ran = 0;
let sweeps = (self.options.max_sweeps / FIT_STAGES.len()).max(1);
for &weight in FIT_STAGES {
opt.fit = weight;
ran = opt.run(sweeps, 1e-4);
}
let (_, boundary_distance) = opt.snap(MIN_SNAP_QUALITY);
let inverted: Vec<P> = w
.hexes
.iter()
.filter(|hx| smooth::min_scaled_jacobian(&hx.map(|v| opt.points[v as usize])) <= 0.0)
.map(|hx| centroid(&opt.points, hx))
.collect();
if !inverted.is_empty() {
let why = format!("{} elements stay inverted after untangling", inverted.len());
return Ok(Attempt::Retry(Retry { at: inverted, why }));
}
opt.improve_worst(IMPROVE_BELOW, 4);
let uncertified = opt.repair_certified(6);
if !uncertified.is_empty() {
let at: Vec<P> = uncertified.iter().filter_map(|&e| w.hexes.get(e as usize)).map(|hx| centroid(&opt.points, hx)).collect();
let why = format!("{} elements fail the certified validity check", at.len());
return Ok(Attempt::Retry(Retry { at, why }));
}
w.points = opt.points;
self.finish(&w, &tree, ran, boundary_distance)
}
/// The core: elements wholly inside the part, cleaned to a 2-manifold boundary whose
/// vertices can all move outward.
fn core(&self, lattice: &grid::LatticeMesh, points: &[P], dist: &[f64]) -> Vec<bool> {
let inside: Vec<bool> = points.iter().map(|&p| self.g.inside(p) == Some(true)).collect();
let mut keep: Vec<bool> = lattice
.hexes
.iter()
.zip(&lattice.level)
.map(|(hx, &l)| {
let floor = self.options.core_offset * self.edge(l);
hx.iter().all(|&v| inside.get(v as usize) == Some(&true) && dist.get(v as usize).is_some_and(|&d| d >= floor))
})
.collect();
for _ in 0..CORE_ROUNDS {
let mut drop = topo::nonmanifold(&lattice.hexes, &keep);
if drop.is_empty() {
drop = topo::hidden_vertices(&lattice.hexes, &keep, &|v| points.get(v as usize).copied().unwrap_or([0.0; 3]), 0.1);
}
if drop.is_empty() {
break;
}
for d in drop {
if let Some(k) = keep.get_mut(d as usize) {
*k = false;
}
}
}
keep
}
/// The boundary layers: padding on the core's boundary quads `qb`, whose outer copies take the
/// map's roles and start halfway to the boundary along the core's normals, then a pillow
/// under each patch, whose copies start a quarter of the way into their elements. Every
/// element starts valid or nearly so; the fit untangles the rest.
fn layers(&self, w: &mut Work, qb: &[(topo::FaceRef, [u32; 4])], fmap: &map::FeatureMap, dist: &[f64]) -> Result<()> {
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
let core_count = w.core_count;
let copy = topo::pad(&mut w.points, &mut w.hexes, &q)?;
w.role = vec![Constraint::Free; w.points.len()];
let mut normal: BTreeMap<u32, P> = BTreeMap::new();
for f in &q {
let p = f.map(|v| w.points[v as usize]);
let n = cross(sub(p[2], p[0]), sub(p[3], p[1]));
let l = norm(n);
if l > 0.0 {
for &v in f {
let e = normal.entry(v).or_insert([0.0; 3]);
for k in 0..3 {
e[k] += n[k] / l;
}
}
}
}
for (&v, &c) in &copy {
if let Some(r) = fmap.role.get(&v) {
w.role[c as usize] = *r;
}
let n = normal.get(&v).copied().unwrap_or([0.0; 3]);
let l = norm(n);
let d = 0.5 * dist.get(v as usize).copied().unwrap_or(0.0);
if l > 0.0 {
w.points[c as usize] = [0, 1, 2].map(|k| w.points[v as usize][k] + d * n[k] / l);
}
}
for ((owner, _), _) in qb {
w.size.push(0.5 * w.size.get(*owner as usize).copied().unwrap_or(self.h));
}
let patches: BTreeSet<u32> = fmap.quad_patch.iter().copied().collect();
for p in patches {
let inside: Vec<bool> =
(0..w.hexes.len()).map(|i| i >= core_count && i < core_count + q.len() && fmap.quad_patch.get(i - core_count) == Some(&p)).collect();
let before = w.hexes.len();
let centroids: Vec<P> = w.hexes.iter().map(|hx| centroid(&w.points, hx)).collect();
let copies = topo::pillow(&mut w.points, &mut w.hexes, &inside)?;
w.role.resize(w.points.len(), Constraint::Free);
for pc in &copies {
let orig = w.points[pc.original as usize];
let n = pc.elements.len().max(1) as f64;
let mut c = [0.0; 3];
for &e in &pc.elements {
let ce = centroids.get(e as usize).copied().unwrap_or(orig);
for k in 0..3 {
c[k] += ce[k] / n;
}
}
w.points[pc.copy as usize] = [0, 1, 2].map(|k| orig[k] + 0.25 * (c[k] - orig[k]));
w.role[pc.copy as usize] = match w.role.get(pc.original as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => Constraint::Free,
_ => Constraint::Patch(p),
};
}
for hx in &w.hexes[before..] {
w.size.push(edge_mean(&w.points, hx));
}
}
Ok(())
}
/// The gate and the output: every element proven valid, nodes renumbered, boundary faces with
/// their B-rep faces.
fn finish(&self, w: &Work, tree: &Tree, sweeps: usize, boundary_distance: f64) -> Result<Attempt> {
let g = self.g;
// One certified pass: every element's quality (the same metric as mesh.quality and
// mesh.hex.generate report) and its Jacobian check for the gate.
let mut bad: Vec<P> = Vec::new();
let mut checks = Vec::with_capacity(w.hexes.len());
let (mut sj_min, mut sj_sum) = (f64::INFINITY, 0.0);
for (e, hx) in w.hexes.iter().enumerate() {
let q = element_quality(ElementKind::Hex8, &hx.map(|v| w.points[v as usize]))?;
let check = q.jacobian.ok_or_else(|| Error::Failed("an element was not certified".into()))?;
if check.validity != Validity::Valid {
bad.push(centroid(&w.points, hx));
}
let sj = q.get(Metric::ScaledJacobian).unwrap_or(-1.0);
sj_min = sj_min.min(sj);
sj_sum += sj;
checks.push((e as u32 + 1, check));
}
if !bad.is_empty() {
let why = format!("{} elements fail the certified validity check", bad.len());
return Ok(Attempt::Retry(Retry { at: bad, why }));
}
// The volume the mesh's own boundary encloses equals the elements' total exactly when they
// tile it without overlapping.
let faces = topo::boundary(&w.hexes);
let enclosed: f64 = faces.iter().map(|(_, quad)| face_flux(&quad.map(|v| w.points[v as usize]))).sum();
let gate =
validity_gate_checks(checks.iter().map(|(l, c)| (*l, c)), &GateOptions { expected_volume: Some(enclosed), volume_tolerance: 1e-9 })?
.into_result()?;
let volume = gate.volume;
let mut new_id = vec![u32::MAX; w.points.len()];
let mut nodes = Vec::new();
let mut hexes = Vec::with_capacity(w.hexes.len());
for hx in &w.hexes {
let mut o = [0u32; 8];
for (k, &v) in hx.iter().enumerate() {
if new_id[v as usize] == u32::MAX {
new_id[v as usize] = u32::try_from(nodes.len()).map_err(|_| Error::Limit("too many nodes".into()))?;
nodes.push(w.points[v as usize]);
}
o[k] = new_id[v as usize];
}
hexes.push(o);
}
let mut boundary = Vec::new();
for ((e, f), quad) in faces {
let patch = quad.iter().find_map(|&v| match w.role.get(v as usize) {
Some(Constraint::Patch(p)) => Some(*p),
_ => None,
});
let c = [0, 1, 2].map(|k| quad.iter().map(|&v| w.points[v as usize][k]).sum::<f64>() / 4.0);
let brep_face = match patch.and_then(|p| g.patches.get(p as usize)) {
Some(pt) if pt.faces.len() == 1 => pt.faces[0],
_ => {
let keep = |t: u32| patch.is_none_or(|p| g.tri_patch.get(t as usize) == Some(&p));
g.nearest(c, keep).and_then(|(t, _, _)| g.surface.tri_face().get(t as usize).copied()).unwrap_or(0)
}
};
boundary.push(BoundaryFace { element: e, face: f + 1, brep_face });
}
let report = OctreeReport {
attempts: 0,
grid_cells: tree.leaf_count(),
core_elements: w.core_count,
layer_elements: w.hexes.len() - w.core_count,
frame: self.frame,
corners: g.corners.len(),
curves: g.curves.len(),
patches: g.patches.len(),
sweeps,
min_scaled_jacobian: sj_min,
mean_scaled_jacobian: sj_sum / w.hexes.len().max(1) as f64,
volume,
surface_volume: g.surface.volume(),
boundary_distance,
retries: Vec::new(),
};
Ok(Attempt::Done(Box::new(OctreeHexMesh { nodes, hexes, boundary, report })))
}
}
/// The core boundary's components and genus against the part's shells; on a mismatch, a retry
/// that refines near the whole boundary.
fn check_topology(g: &Geometry, quads: &[[u32; 4]]) -> Option<Retry> {
let edges = topo::quad_edges(quads);
let mut comp: Vec<usize> = (0..quads.len()).collect();
for qs in edges.values() {
for w in qs.windows(2) {
let (a, b) = (topo::find(&mut comp, w[0] as usize), topo::find(&mut comp, w[1] as usize));
comp[a] = b;
}
}
// Per component: vertices, edges, faces.
let mut stats: BTreeMap<usize, (BTreeSet<u32>, i64, i64)> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
let s = stats.entry(topo::find(&mut comp, i)).or_default();
s.2 += 1;
s.0.extend(q.iter().copied());
}
for qs in edges.values() {
if let Some(&q0) = qs.first()
&& let Some(s) = stats.get_mut(&topo::find(&mut comp, q0 as usize))
{
s.1 += 1;
}
}
let mut got: Vec<i64> = stats.values().map(|s| (2 - (s.0.len() as i64 - s.1 + s.2)) / 2).collect();
let mut want: Vec<i64> = g.surface.shells().iter().map(|s| i64::from(s.genus)).collect();
got.sort_unstable();
want.sort_unstable();
if got == want {
return None;
}
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
Some(Retry { at, why: format!("the core's boundary has components of genus {got:?}, the part {want:?}") })
}
+641
View File
@@ -0,0 +1,641 @@
//! Untangling, fitting and shape optimisation of the hexahedral mesh.
//!
//! # Energy
//! The foldover-free mapping energy of V. Garanzha, I. Kaporin, L. Kudryavtseva, F. Protais,
//! N. Ray, D. Sokolov, *Foldover-free maps in 50 lines of code*, ACM TOG 40(4):102, 2021 (paper
//! only; the authors' code is AGPL and is not read). Each hexahedron is measured at nine points,
//! its eight corners (the trapezoidal rule the paper uses for quads) and its centre, where a
//! twisted element folds first: `J` is the sample's three edge directions against the ideal cube
//! of the element's target edge `s`, `D = det J`, and the sample costs
//!
//! `f_ε + λ g_ε`, `f_ε = |J|³_F / χ(D, ε)`, `g_ε = (D² + 1) / χ(D, ε)`,
//! `χ(D, ε) = (D + √(ε² + D²)) / 2`,
//!
//! weighted by `s³`. `f` is the shape (conformal) term, `g` the volume term. The paper's shape
//! term is `|J|²/D^{2/3}`; its 3/2 power `|J|³/D`, the classical polyconvex distortion in three
//! dimensions (J. M. Ball, 1977), is minimal at the same ideal shapes and needs only square
//! roots, so the optimiser gives the same bits on every platform (no `pow` of the system maths
//! library). `χ` keeps both finite for inverted samples, and `ε` follows the paper's rule (7),
//! `ε = √(10⁻¹² + 0.04 min(0, min D)²)`, so it vanishes once every sample is positive and the
//! energy becomes a barrier against inversion.
//!
//! A vertex on a corner, curve or patch adds `fit · 8 s̄ · dist²` (`s̄` the mean target edge of its
//! elements) to its distance from that feature: with `fit` raised stage by stage the boundary is
//! drawn onto the geometry while the barrier keeps every element valid (Gao, Shen, Panozzo 2019,
//! §3.5). With `fit = 0` constrained vertices are held on their features instead (each step is
//! restricted to the tangent and projected back).
//!
//! # Minimisation
//! Nonlinear Gauss–Seidel: each vertex in turn takes a Newton step on its own energy with the
//! paper's positive approximation of the Hessian (the second derivatives of `χ` and `det` are
//! dropped, §2.3, App. A), accepted only if the energy decreased (halving line search). The paper
//! minimises the same energy globally with L-BFGS; the vertex sweep is the local form of the same
//! descent. Then a compass search raises the worst corner scaled Jacobians, and another repairs
//! elements whose trilinear map the certified check does not prove positive. Sweeps, line-search
//! steps, compass steps and rounds are capped.
use fieldcraft_model::ElementKind;
use crate::quality::{Validity, element_validity};
use crate::vec3::{P, cross, dot, norm, sub};
use super::geom::dist2;
use super::geom::Geometry;
/// How a vertex may move.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Constraint {
Free,
/// On a corner of the part (the boundary surface's vertex).
Corner(u32),
/// On a feature curve.
Curve(u32),
/// On a patch of the boundary.
Patch(u32),
}
/// Model-order corner `c` of a hex8: its three neighbours along x, y, z and the signs that make
/// the reference corner frame right-handed.
const CORNER_FRAMES: [([usize; 3], [f64; 3]); 8] = [
([1, 3, 4], [1.0, 1.0, 1.0]),
([0, 2, 5], [-1.0, 1.0, 1.0]),
([3, 1, 6], [-1.0, -1.0, 1.0]),
([2, 0, 7], [1.0, -1.0, 1.0]),
([5, 7, 0], [1.0, 1.0, -1.0]),
([4, 6, 1], [-1.0, 1.0, -1.0]),
([7, 5, 2], [-1.0, -1.0, -1.0]),
([6, 4, 3], [1.0, -1.0, -1.0]),
];
/// Weight of the volume term.
const LAMBDA: f64 = 0.1;
fn chi(d: f64, eps: f64) -> f64 {
0.5 * (d + (eps * eps + d * d).sqrt())
}
fn chi_d(d: f64, eps: f64) -> f64 {
0.5 * (1.0 + d / (eps * eps + d * d).sqrt())
}
/// The corner Jacobian (columns) of corner `c` of `x` against an ideal cube of edge `s`.
fn corner_jacobian(x: &[P; 8], c: usize, s: f64) -> [P; 3] {
let (n, sg) = CORNER_FRAMES[c];
[0, 1, 2].map(|a| sub(x[n[a]], x[c]).map(|v| v * sg[a] / s))
}
/// The sample points of the energy: the eight corners and the centre. Column `a` of sample `s`'s
/// Jacobian is `Σ_k W[s][a][k] x_k` (before dividing by the target edge). The centre keeps
/// twisted elements (positive corners, folded inside) out of reach, as the nine-point measure of
/// ParaView and of Zhang et al.'s meshers does.
const SAMPLES: usize = 9;
fn sample_weights(s: usize) -> [[f64; 8]; 3] {
let mut w = [[0.0; 8]; 3];
if s < 8 {
let (n, sg) = CORNER_FRAMES[s];
for a in 0..3 {
w[a][n[a]] += sg[a];
w[a][s] -= sg[a];
}
} else {
w = [
[-0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25, -0.25],
[-0.25, -0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25],
[-0.25, -0.25, -0.25, -0.25, 0.25, 0.25, 0.25, 0.25],
];
}
w
}
/// The Jacobian (columns) of sample `smp` of `x` against an ideal cube of edge `s`.
fn sample_jacobian(x: &[P; 8], smp: usize, s: f64) -> [P; 3] {
let w = sample_weights(smp);
[0, 1, 2].map(|a| {
let mut c = [0.0; 3];
for (k, p) in x.iter().enumerate() {
for r in 0..3 {
c[r] += w[a][k] * p[r];
}
}
c.map(|v| v / s)
})
}
fn det3(j: &[P; 3]) -> f64 {
dot(j[0], cross(j[1], j[2]))
}
/// The cofactor columns: `∂ det / ∂ column a`.
fn cofactors(j: &[P; 3]) -> [P; 3] {
[cross(j[1], j[2]), cross(j[2], j[0]), cross(j[0], j[1])]
}
/// The energy of one corner.
fn corner_energy(j: &[P; 3], eps: f64) -> f64 {
let d = det3(j);
let c = chi(d, eps);
let fro: f64 = j.iter().map(|v| dot(*v, *v)).sum();
fro * fro.sqrt() / c + LAMBDA * (d * d + 1.0) / c
}
/// The scaled Jacobian of each corner, smallest (`min over corners of det / product of edge
/// lengths`), the usual hex quality measure.
pub(crate) fn min_scaled_jacobian(x: &[P; 8]) -> f64 {
(0..8)
.map(|c| {
let j = corner_jacobian(x, c, 1.0);
let l = norm(j[0]) * norm(j[1]) * norm(j[2]);
if l > 0.0 { det3(&j) / l } else { -1.0 }
})
.fold(f64::INFINITY, f64::min)
}
/// The optimiser's state.
pub(crate) struct Optimizer<'a> {
pub geom: &'a Geometry<'a>,
pub points: Vec<P>,
pub hexes: &'a [[u32; 8]],
/// Target edge per element.
pub size: &'a [f64],
pub constraint: Vec<Constraint>,
/// Elements around each vertex with the vertex's local position.
around: Vec<Vec<(u32, u8)>>,
pub eps: f64,
/// Weight of the fitting term; 0 holds boundary vertices on their features exactly.
pub fit: f64,
}
impl<'a> Optimizer<'a> {
pub fn new(geom: &'a Geometry<'a>, points: Vec<P>, hexes: &'a [[u32; 8]], size: &'a [f64], constraint: Vec<Constraint>) -> Optimizer<'a> {
let mut around: Vec<Vec<(u32, u8)>> = vec![Vec::new(); points.len()];
for (e, h) in hexes.iter().enumerate() {
for (k, &v) in h.iter().enumerate() {
if let Some(a) = around.get_mut(v as usize) {
a.push((e as u32, k as u8));
}
}
}
Optimizer { geom, points, hexes, size, constraint, around, eps: 1.0, fit: 0.0 }
}
fn element(&self, e: u32) -> ([P; 8], f64) {
let h = self.hexes.get(e as usize).copied().unwrap_or([0; 8]);
(h.map(|v| self.points.get(v as usize).copied().unwrap_or([0.0; 3])), self.size.get(e as usize).copied().unwrap_or(1.0))
}
/// The smallest normalised corner determinant over the mesh, and the total energy.
pub fn measure(&self) -> (f64, f64) {
let (mut m, mut total) = (f64::INFINITY, 0.0);
for e in 0..self.hexes.len() as u32 {
let (x, s) = self.element(e);
for c in 0..SAMPLES {
let j = sample_jacobian(&x, c, s);
m = m.min(det3(&j));
total += s * s * s * corner_energy(&j, self.eps);
}
}
(m, total)
}
/// Where vertex `v`'s feature is closest to `p` (`None` for a free vertex).
pub fn target(&self, v: u32, p: P) -> Option<P> {
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => None,
Constraint::Corner(c) => Some(self.geom.xyz(c)),
Constraint::Curve(c) => self.geom.nearest_on_curve(c, p).map(|x| x.0),
Constraint::Patch(pt) => self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)).map(|x| x.1),
}
}
/// The fitting weight of vertex `v`: the fit factor times its elements' size (so a distance
/// of one element weighs like the shape of its corners), ten times more for a corner.
fn fit_weight(&self, v: u32) -> f64 {
let a = self.around.get(v as usize).map_or(&[][..], |a| a.as_slice());
if a.is_empty() || self.fit == 0.0 {
return 0.0;
}
let s = a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(1.0)).sum::<f64>() / a.len() as f64;
let corner = if matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))) { 10.0 } else { 1.0 };
8.0 * self.fit * corner * s
}
/// The energy of the corners that involve vertex `v`, with its fitting term.
fn local_energy(&self, v: u32) -> f64 {
let mut total = 0.0;
if self.fit > 0.0 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
if let Some(t) = self.target(v, p) {
total += self.fit_weight(v) * dist2(p, t);
}
}
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
let (x, s) = self.element(e);
let (n, _) = CORNER_FRAMES[k as usize];
for c in [k as usize, n[0], n[1], n[2], 8] {
total += s * s * s * corner_energy(&sample_jacobian(&x, c, s), self.eps);
}
}
total
}
/// The gradient and approximate Hessian of the local energy at vertex `v`.
fn derivatives(&self, v: u32) -> (P, [[f64; 3]; 3]) {
let mut g = [0.0; 3];
let mut h = [[0.0; 3]; 3];
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
let (x, s) = self.element(e);
let k = k as usize;
let w = s * s * s;
let (nk, _) = CORNER_FRAMES[k];
for c in [k, nk[0], nk[1], nk[2], 8] {
// ∂ column a / ∂ x_v = coef[a] I.
let wt = sample_weights(c);
let coef: [f64; 3] = [0, 1, 2].map(|a| wt[a][k] / s);
let j = sample_jacobian(&x, c, s);
let d = det3(&j);
let ch = chi(d, self.eps);
let chd = chi_d(d, self.eps);
let fro: f64 = j.iter().map(|q| dot(*q, *q)).sum();
let cof = cofactors(&j);
let root = fro.sqrt();
// dE/dJ = A J + B cof(J).
let a_coef = 3.0 * root / ch;
let f_d = -fro * root * chd / (ch * ch);
let g_d = 2.0 * d / ch - (d * d + 1.0) * chd / (ch * ch);
let b_coef = f_d + LAMBDA * g_d;
let mut dd = [0.0; 3];
let mut sum_c2 = 0.0;
for a in 0..3 {
if coef[a] == 0.0 {
continue;
}
sum_c2 += coef[a] * coef[a];
for r in 0..3 {
g[r] += w * coef[a] * (a_coef * j[a][r] + b_coef * cof[a][r]);
dd[r] += coef[a] * cof[a][r];
}
}
// Positive part of the second derivative along D, χ'' and det'' dropped.
let f_dd = 2.0 * fro * root * chd * chd / (ch * ch * ch);
let g_dd = 2.0 / ch - 4.0 * d * chd / (ch * ch) + 2.0 * (d * d + 1.0) * chd * chd / (ch * ch * ch);
let k_dd = (f_dd + LAMBDA * g_dd).max(0.0);
for r in 0..3 {
h[r][r] += w * a_coef * sum_c2;
for q in 0..3 {
h[r][q] += w * k_dd * dd[r] * dd[q];
}
}
}
}
if self.fit > 0.0 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
if let Some(t) = self.target(v, p) {
let w = self.fit_weight(v);
for r in 0..3 {
g[r] += 2.0 * w * (p[r] - t[r]);
h[r][r] += 2.0 * w;
}
}
}
(g, h)
}
/// Project a point onto vertex `v`'s constraint.
fn project(&self, v: u32, p: P) -> P {
self.target(v, p).unwrap_or(p)
}
/// The direction a constrained vertex may move along: the step restricted to its tangent.
fn restrict(&self, v: u32, d: P) -> P {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
Constraint::Free => d,
Constraint::Corner(_) => [0.0; 3],
Constraint::Curve(c) => {
let Some((_, i, _)) = self.geom.nearest_on_curve(c, p) else { return [0.0; 3] };
let Some(cv) = self.geom.curves.get(c as usize) else { return [0.0; 3] };
let (a, b) = (cv.vertices.get(i).copied().unwrap_or(0), cv.vertices.get(i + 1).copied().unwrap_or(0));
let t = sub(self.geom.xyz(b), self.geom.xyz(a));
let l2 = dot(t, t);
if l2 > 0.0 { t.map(|x| x * dot(d, t) / l2) } else { [0.0; 3] }
}
Constraint::Patch(pt) => {
let Some((t, _, _)) = self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)) else { return [0.0; 3] };
let n = self.geom.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
let dn = dot(d, n);
[0, 1, 2].map(|k| d[k] - dn * n[k])
}
}
}
/// One Newton step of vertex `v`; whether it moved.
fn relax(&mut self, v: u32) -> bool {
let soft = self.fit > 0.0;
if (!soft && matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))))
|| self.around.get(v as usize).is_none_or(|a| a.is_empty())
{
return false;
}
let (g, h) = self.derivatives(v);
let Some(step) = solve3(h, g.map(|x| -x)) else { return false };
let step = if soft { step } else { self.restrict(v, step) };
if !step.iter().all(|x| x.is_finite()) || norm(step) == 0.0 {
return false;
}
let old = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let e0 = self.local_energy(v);
let mut tau = 1.0;
for _ in 0..12 {
let moved = [0, 1, 2].map(|k| old[k] + tau * step[k]);
let trial = if soft { moved } else { self.project(v, moved) };
if let Some(p) = self.points.get_mut(v as usize) {
*p = trial;
}
let e1 = self.local_energy(v);
if e1 < e0 && e1.is_finite() {
return true;
}
tau *= 0.5;
}
if let Some(p) = self.points.get_mut(v as usize) {
*p = old;
}
false
}
/// Set the regularisation from the smallest corner determinant (Garanzha et al. 2021: ε
/// shrinks as the mesh untangles); that determinant and the energy at the new ε.
fn update_eps(&mut self) -> (f64, f64) {
let (min_d, energy) = self.measure();
let old = self.eps;
self.eps = (1e-12 + 0.04 * min_d.min(0.0).powi(2)).sqrt();
(min_d, if self.eps == old { energy } else { self.measure().1 })
}
/// Sweep all vertices until every corner is positive and the energy settles, or `max_sweeps`;
/// the sweeps run.
pub fn run(&mut self, max_sweeps: usize, tolerance: f64) -> usize {
let n = self.points.len() as u32;
let (_, mut energy) = self.update_eps();
let mut sweeps = 0;
for sweep in 0..max_sweeps {
let mut moved = 0usize;
for v in 0..n {
if self.relax(v) {
moved += 1;
}
}
let old_eps = self.eps;
let (min_d, e) = self.update_eps();
sweeps = sweep + 1;
let settled = old_eps == self.eps && (energy - e).abs() <= tolerance * energy.abs();
energy = e;
if moved == 0 || (min_d > 0.0 && settled) {
break;
}
}
sweeps
}
}
impl Optimizer<'_> {
/// The smallest corner scaled Jacobian of the elements around vertex `v`.
fn local_quality(&self, v: u32) -> f64 {
self.around
.get(v as usize)
.map_or(f64::INFINITY, |a| a.iter().map(|&(e, _)| min_scaled_jacobian(&self.element(e).0)).fold(f64::INFINITY, f64::min))
}
/// Move every constrained vertex exactly onto its feature where its elements keep a corner
/// scaled Jacobian of at least `floor`; the others stay where the fit left them. Returns
/// how many stayed off and the largest distance of a constrained vertex from its feature.
pub fn snap(&mut self, floor: f64) -> (usize, f64) {
let (mut off, mut worst) = (0usize, 0.0f64);
for v in 0..self.points.len() as u32 {
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let Some(t) = self.target(v, p) else { continue };
if p == t {
continue;
}
if let Some(x) = self.points.get_mut(v as usize) {
*x = t;
}
if self.local_quality(v) < floor {
if let Some(x) = self.points.get_mut(v as usize) {
*x = p;
}
off += 1;
worst = worst.max(dist2(p, t).sqrt());
}
}
(off, worst)
}
/// Raise the worst elements: every vertex of an element whose corner scaled Jacobian is
/// below `threshold` moves, within its constraint, by a compass search that maximises the
/// smallest scaled Jacobian around it (steps shrink by halves; at most `passes` rounds over
/// the worst elements, 12 step sizes per vertex).
pub fn improve_worst(&mut self, threshold: f64, passes: usize) {
for _ in 0..passes {
let mut verts: Vec<u32> = Vec::new();
for (e, h) in self.hexes.iter().enumerate() {
if min_scaled_jacobian(&self.element(e as u32).0) < threshold {
verts.extend(h.iter().copied());
}
}
verts.sort_unstable();
verts.dedup();
if verts.is_empty() {
break;
}
let mut moved = false;
for &v in &verts {
if matches!(self.constraint.get(v as usize), Some(Constraint::Free)) {
moved |= self.compass(v, &|o: &Self, v| o.local_quality(v));
}
}
if !moved {
break;
}
}
}
/// Compass search on vertex `v` (within its constraint) for the largest `quality`; steps
/// start at 0.15 of its largest element size and halve when no direction improves, 12 times
/// at most. Whether it moved.
fn compass(&mut self, v: u32, quality: &dyn Fn(&Self, u32) -> f64) -> bool {
let p0 = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
let reach = self
.around
.get(v as usize)
.map_or(0.0, |a| a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(0.0)).fold(0.0, f64::max));
let mut step = 0.15 * reach;
let mut best = quality(self, v);
let mut cur = p0;
let mut moved = false;
for _ in 0..12 {
if step <= 1e-9 * reach.max(1e-300) {
break;
}
let mut improved = false;
for d in [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]] {
let dir = self.restrict(v, d.map(|x| x * step));
if norm(dir) == 0.0 {
continue;
}
let trial = self.project(v, [0, 1, 2].map(|k| cur[k] + dir[k]));
if let Some(p) = self.points.get_mut(v as usize) {
*p = trial;
}
let q = quality(self, v);
if q > best + 1e-12 {
best = q;
cur = trial;
improved = true;
moved = true;
} else if let Some(p) = self.points.get_mut(v as usize) {
*p = cur;
}
}
if !improved {
step *= 0.5;
}
}
if let Some(p) = self.points.get_mut(v as usize) {
*p = cur;
}
moved
}
/// The smallest certified Jacobian ratio of the elements around `v` (negative where the
/// trilinear map folds somewhere, even with positive corners).
fn certified_quality(&self, v: u32) -> f64 {
self.around.get(v as usize).map_or(f64::INFINITY, |a| {
a.iter()
.map(|&(e, _)| {
let x = self.element(e).0;
if min_scaled_jacobian(&x) <= 0.0 {
return -1e9;
}
element_validity(ElementKind::Hex8, &x).map_or(-1.0, |c| match c.validity {
Validity::Valid => c.ratio.max(1e-300),
_ => c.ratio.min(0.0) - 1e-12,
})
})
.fold(f64::INFINITY, f64::min)
})
}
/// Repair elements whose trilinear map is not proven positive although their corners are:
/// the vertices of each such element (free ones, and boundary ones along their features)
/// move by compass search to raise the smallest certified bound around them; `rounds` rounds
/// at most. Returns the elements still not proven valid.
pub fn repair_certified(&mut self, rounds: usize) -> Vec<u32> {
let check = |o: &Self, e: u32| element_validity(ElementKind::Hex8, &o.element(e).0).is_ok_and(|c| c.validity == Validity::Valid);
let mut bad: Vec<u32> = (0..self.hexes.len() as u32).filter(|&e| !check(self, e)).collect();
for _ in 0..rounds {
if bad.is_empty() {
break;
}
let mut verts: Vec<u32> = bad.iter().flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8])).collect();
verts.sort_unstable();
verts.dedup();
for v in verts {
if !matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_)) | None) {
self.compass(v, &|o: &Self, v| o.certified_quality(v));
}
}
let mut around: Vec<u32> = bad
.iter()
.flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8]))
.flat_map(|v| self.around.get(v as usize).map_or(Vec::new(), |a| a.iter().map(|x| x.0).collect()))
.collect();
around.sort_unstable();
around.dedup();
bad = around.into_iter().filter(|&e| !check(self, e)).collect();
}
bad
}
}
/// Solve the symmetric 3 × 3 system `h x = b` (Cramer), `None` when singular.
fn solve3(h: [[f64; 3]; 3], b: P) -> Option<P> {
let d = dot(h[0], cross(h[1], h[2]));
if !(d.abs() > 1e-300) || !d.is_finite() {
return None;
}
let col = |k: usize| [h[0][k], h[1][k], h[2][k]];
let (c0, c1, c2) = (col(0), col(1), col(2));
Some([dot(b, cross(c1, c2)) / d, dot(c0, cross(b, c2)) / d, dot(c0, cross(c1, b)) / d])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::octree::geom::tests::box_surface;
#[test]
fn corner_frames_are_right_handed_on_the_unit_cube() {
let cube: [P; 8] = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
for c in 0..8 {
let j = corner_jacobian(&cube, c, 1.0);
assert!((det3(&j) - 1.0).abs() < 1e-15, "corner {c}");
}
assert!((min_scaled_jacobian(&cube) - 1.0).abs() < 1e-15);
}
#[test]
fn a_tangled_interior_vertex_is_untangled() {
// A 2 × 2 × 2 block of cubes with the centre vertex pushed through a face; the boundary
// fixed.
let s = box_surface([0.0; 3], [2.0; 3]);
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
let id = |x: u32, y: u32, z: u32| x + 3 * (y + 3 * z);
let mut p = Vec::new();
for z in 0..3 {
for y in 0..3 {
for x in 0..3 {
p.push([x as f64, y as f64, z as f64]);
}
}
}
let mut h = Vec::new();
for z in 0..2 {
for y in 0..2 {
for x in 0..2 {
h.push([
id(x, y, z),
id(x + 1, y, z),
id(x + 1, y + 1, z),
id(x, y + 1, z),
id(x, y, z + 1),
id(x + 1, y, z + 1),
id(x + 1, y + 1, z + 1),
id(x, y + 1, z + 1),
]);
}
}
}
let centre = id(1, 1, 1) as usize;
p[centre] = [1.7, 1.9, 0.2];
let size = vec![1.0; h.len()];
// Boundary vertices slide on the box's faces (hard constraints, patch of the nearest
// face), the centre is free.
let cons: Vec<Constraint> = (0..p.len())
.map(|i| {
if i == centre {
return Constraint::Free;
}
let t = g.nearest(p[i], |_| true).unwrap().0;
Constraint::Patch(g.tri_patch[t as usize])
})
.collect();
let mut o = Optimizer::new(&g, p, &h, &size, cons);
assert!(o.measure().0 < 0.0);
let st = o.run(200, 1e-9);
assert!(o.measure().0 > 0.9, "{st:?}");
assert!(sub(o.points[centre], [1.0; 3]).iter().all(|c| c.abs() < 1e-3), "{:?}", o.points[centre]);
}
}
+374
View File
@@ -0,0 +1,374 @@
//! The 3-refinement transition templates: how a leaf of the balanced tree is cut into
//! hexahedra so that it conforms to finer neighbours.
//!
//! Every vertex of a leaf is *marked* when a finer leaf touches it. Under strong balance the
//! marked set of a leaf is one of five patterns (after the closure in [`super::grid`] has split
//! every leaf that had another one), each with one template:
//!
//! | marks | template | hexahedra |
//! |---|---|---|
//! | none | the leaf itself | 1 |
//! | one corner | corner cube and three blocks fanning to the far corner | 4 |
//! | the two ends of an edge | three cubes along the edge and two four-hex slabs | 11 |
//! | the four corners of a face | nine cubes on the face, nine above them, four on top | 22 |
//! | all eight | the 3 × 3 × 3 split | 27 |
//!
//! A face of a leaf is cut according to its own marks only (none, one corner, one edge, all
//! four), and an edge into thirds at its marked ends, so two leaves sharing a face cut it the same
//! way: the mesh is conforming by construction. This is the node-marking form of Schneiders'
//! 3-refinement (R. Schneiders, *Refining quadrilateral and hexahedral element meshes*, NUMGRID
//! 1996; Y. Ito, A. M. Shih, B. K. Soni, *Octree-based reasonable-quality hexahedral mesh
//! generation using a new set of refinement templates*, IJNME 77(13):1809–1833, 2009). The
//! templates are our own construction from those papers' description: the face template's top
//! block is the 3 × 3 → 1 transition traced from its dual sheets, the edge template is two
//! copies of the planar 3 → 1 transition swept across the leaf, and the tests prove every
//! template's faces, volume and validity.
//!
//! Coordinates are integers in sixths of the leaf's edge, so every vertex shared with a neighbour
//! lies on the global integer lattice of [`super::grid`] and coincident vertices merge exactly.
/// A point of a leaf in sixths of its edge, `0..=6` per axis.
pub(crate) type Local = [i64; 3];
/// A hexahedron in the model's node order (corners 1-2-3-4 then 5-6-7-8 above them).
pub(crate) type Hex = [Local; 8];
/// The marked-corner pattern of a leaf, up to symmetry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Pattern {
None,
Corner,
Edge,
Face,
Full,
}
/// A symmetry of the cube: coordinate `k` of the image is coordinate `perm[k]` of the point,
/// reflected (`6 − v`) when `flip[k]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Sym {
perm: [usize; 3],
flip: [bool; 3],
}
impl Sym {
pub(crate) fn apply(&self, p: Local) -> Local {
let mut q = [0; 3];
for (k, out) in q.iter_mut().enumerate() {
let v = p[self.perm[k]];
*out = if self.flip[k] { 6 - v } else { v };
}
q
}
}
/// The 48 symmetries in a fixed order.
pub(crate) fn symmetries() -> Vec<Sym> {
let perms = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
let mut out = Vec::with_capacity(48);
for perm in perms {
for bits in 0..8u8 {
out.push(Sym { perm, flip: [bits & 1 != 0, bits & 2 != 0, bits & 4 != 0] });
}
}
out
}
/// The leaf corner `c` (bit 0: x, bit 1: y, bit 2: z) in sixths.
pub(crate) fn corner(c: usize) -> Local {
[(c & 1) as i64 * 6, ((c >> 1) & 1) as i64 * 6, ((c >> 2) & 1) as i64 * 6]
}
/// The corner index of a corner point.
fn corner_index(p: Local) -> usize {
usize::from(p[0] == 6) | (usize::from(p[1] == 6) << 1) | (usize::from(p[2] == 6) << 2)
}
/// The image of a corner mask under a symmetry.
fn map_mask(sym: &Sym, mask: u8) -> u8 {
(0..8).filter(|c| mask & (1 << c) != 0).fold(0u8, |m, c| m | (1 << corner_index(sym.apply(corner(c)))))
}
/// The pattern of a corner mask, or `None` when no template fits it.
pub(crate) fn classify(mask: u8) -> Option<Pattern> {
let corners: Vec<Local> = (0..8).filter(|c| mask & (1 << c) != 0).map(corner).collect();
let shared = |n: usize| (0..3).filter(|&k| corners.iter().all(|p| p[k] == corners[0][k])).count() == n;
match corners.len() {
0 => Some(Pattern::None),
1 => Some(Pattern::Corner),
2 if shared(2) => Some(Pattern::Edge),
4 if shared(1) => Some(Pattern::Face),
8 => Some(Pattern::Full),
_ => None,
}
}
/// The canonical mask of a pattern: corner 0; edge 0–1 (along x); face z = 0.
fn canonical_mask(p: Pattern) -> u8 {
match p {
Pattern::None => 0,
Pattern::Corner => 0b1,
Pattern::Edge => 0b11,
Pattern::Face => 0b1111,
Pattern::Full => 0xff,
}
}
/// The first symmetry (in [`symmetries`] order) taking the pattern's canonical mask to `mask`.
pub(crate) fn orientation(syms: &[Sym], pattern: Pattern, mask: u8) -> Option<Sym> {
let c = canonical_mask(pattern);
syms.iter().find(|s| map_mask(s, c) == mask).copied()
}
/// Thirds to sixths.
fn t(x: i64, y: i64, z: i64) -> Local {
[2 * x, 2 * y, 2 * z]
}
/// A thirds-aligned cube with lower corner `(x, y, z)` (in thirds) and edge `e` thirds.
fn cube(x: i64, y: i64, z: i64, e: i64) -> Hex {
let (a, b) = (t(x, y, z), t(x + e, y + e, z + e));
[
[a[0], a[1], a[2]],
[b[0], a[1], a[2]],
[b[0], b[1], a[2]],
[a[0], b[1], a[2]],
[a[0], a[1], b[2]],
[b[0], a[1], b[2]],
[b[0], b[1], b[2]],
[a[0], b[1], b[2]],
]
}
/// The planar 3 → 1 transition in `(x, s)`: x in thirds, `s` in halves of the transition height,
/// three segments at `s = 0` and one at `s = 2`.
const THREE_TO_ONE: [[(i64, i64); 4]; 4] =
[[(0, 0), (1, 0), (1, 1), (0, 2)], [(1, 0), (2, 0), (2, 1), (1, 1)], [(2, 0), (3, 0), (3, 2), (2, 1)], [(1, 1), (2, 1), (3, 2), (0, 2)]];
/// The canonical template of a pattern, in sixths of the leaf.
pub(crate) fn template(p: Pattern) -> Vec<Hex> {
match p {
Pattern::None => vec![cube(0, 0, 0, 3)],
Pattern::Full => (0..27).map(|k| cube(k % 3, (k / 3) % 3, k / 9, 1)).collect(),
Pattern::Corner => vec![
cube(0, 0, 0, 1),
[t(1, 0, 0), t(3, 0, 0), t(3, 3, 0), t(1, 1, 0), t(1, 0, 1), t(3, 0, 3), t(3, 3, 3), t(1, 1, 1)],
[t(0, 1, 0), t(1, 1, 0), t(3, 3, 0), t(0, 3, 0), t(0, 1, 1), t(1, 1, 1), t(3, 3, 3), t(0, 3, 3)],
[t(0, 0, 1), t(1, 0, 1), t(1, 1, 1), t(0, 1, 1), t(0, 0, 3), t(3, 0, 3), t(3, 3, 3), t(0, 3, 3)],
],
Pattern::Edge => {
let mut out: Vec<Hex> = (0..3).map(|i| cube(i, 0, 0, 1)).collect();
// Two slabs over the edge column's faces y = 1 and z = 1, each the 3 → 1 transition
// swept from the leaf face to the diagonal plane y = z.
for q in THREE_TO_ONE {
let b = |(x, s): (i64, i64), w: i64| t(x, 1 + s, w * (1 + s));
let c = |(x, s): (i64, i64), w: i64| t(x, w * (1 + s), 1 + s);
out.push([b(q[0], 0), b(q[1], 0), b(q[2], 0), b(q[3], 0), b(q[0], 1), b(q[1], 1), b(q[2], 1), b(q[3], 1)]);
out.push([c(q[0], 0), c(q[1], 0), c(q[2], 0), c(q[3], 0), c(q[0], 1), c(q[1], 1), c(q[2], 1), c(q[3], 1)]);
}
out
}
Pattern::Face => {
let mut out: Vec<Hex> = (0..9).map(|k| cube(k % 3, k / 3, 0, 1)).collect();
// The layer above: its top is the corners raised to the leaf's top, the side nodes of
// the 3 → 1 transitions at two thirds, the inside nodes at half height.
let top = |a: i64, b: i64| -> Local {
let edge = |v: i64| v == 0 || v == 3;
match (edge(a), edge(b)) {
(true, true) => [2 * a, 2 * b, 6],
(false, false) => [2 * a, 2 * b, 3],
_ => [2 * a, 2 * b, 4],
}
};
for k in 0..9 {
let (i, j) = (k % 3, k / 3);
out.push([
t(i, j, 1),
t(i + 1, j, 1),
t(i + 1, j + 1, 1),
t(i, j + 1, 1),
top(i, j),
top(i + 1, j),
top(i + 1, j + 1),
top(i, j + 1),
]);
}
// The four on top: the y-arch (low), the x-arch (high) and the two side blocks.
out.push([[2, 2, 3], [4, 2, 3], [4, 4, 3], [2, 4, 3], [2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4]]);
out.push([[2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4], [0, 0, 6], [6, 0, 6], [6, 6, 6], [0, 6, 6]]);
out.push([[2, 2, 3], [2, 4, 3], [2, 6, 4], [2, 0, 4], [0, 2, 4], [0, 4, 4], [0, 6, 6], [0, 0, 6]]);
out.push([[4, 2, 3], [4, 4, 3], [4, 6, 4], [4, 0, 4], [6, 2, 4], [6, 4, 4], [6, 6, 6], [6, 0, 6]]);
out
}
}
}
/// Six times the signed volume of the corner tetrahedron at node 0 (exact on the lattice).
pub(crate) fn corner0_orientation(h: &[[i64; 3]; 8]) -> i128 {
let e = |j: usize| [0, 1, 2].map(|k| i128::from(h[j][k] - h[0][k]));
let (a, b, c) = (e(1), e(3), e(4));
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
}
/// The hexahedron with its orientation reversed (the same cell, mirrored node order).
pub(crate) fn reversed<T: Copy>(h: [T; 8]) -> [T; 8] {
[h[0], h[3], h[2], h[1], h[4], h[7], h[6], h[5]]
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::quality::{Validity, element_validity};
use fieldcraft_model::ElementKind;
/// The face of the leaf a quad lies on (axis, value), if any.
fn on_leaf_face(q: &[Local; 4]) -> Option<(usize, i64)> {
(0..3).find_map(|k| [0, 6].into_iter().find(|&v| q.iter().all(|p| p[k] == v)).map(|v| (k, v)))
}
fn sorted(mut q: [Local; 4]) -> [Local; 4] {
q.sort();
q
}
/// The quads a leaf face must carry for its four corner marks, as sorted vertex sets, on the
/// face `(axis, value)`.
fn face_template(axis: usize, value: i64, marks: [bool; 4]) -> Vec<[Local; 4]> {
// Face coordinates (u, v) are the other two axes in increasing order; corners in order
// (0,0), (6,0), (0,6), (6,6).
let (ua, va) = match axis {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let lift = |u: i64, v: i64| {
let mut p = [0; 3];
p[axis] = value;
p[ua] = u;
p[va] = v;
p
};
// Build in a canonical frame, then map the 2D symmetry that takes the canonical marks to
// these marks.
let count = marks.iter().filter(|m| **m).count();
let canon: Vec<[(i64, i64); 4]> = match count {
0 => vec![[(0, 0), (6, 0), (6, 6), (0, 6)]],
1 => vec![[(0, 0), (2, 0), (2, 2), (0, 2)], [(2, 0), (6, 0), (6, 6), (2, 2)], [(0, 2), (2, 2), (6, 6), (0, 6)]],
2 => {
let mut v: Vec<[(i64, i64); 4]> = (0..3).map(|i| [(2 * i, 0), (2 * i + 2, 0), (2 * i + 2, 2), (2 * i, 2)]).collect();
v.extend([
[(0, 2), (2, 2), (2, 4), (0, 6)],
[(2, 2), (4, 2), (4, 4), (2, 4)],
[(4, 2), (6, 2), (6, 6), (4, 4)],
[(2, 4), (4, 4), (6, 6), (0, 6)],
]);
v
}
_ => (0..9)
.map(|k| {
[(2 * (k % 3), 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3) + 2), (2 * (k % 3), 2 * (k / 3) + 2)]
})
.collect(),
};
let canon_marks: [bool; 4] = match count {
0 => [false; 4],
1 => [true, false, false, false],
2 => [true, true, false, false],
_ => [true; 4],
};
let maps: [fn(i64, i64) -> (i64, i64); 8] = [
|u, v| (u, v),
|u, v| (6 - u, v),
|u, v| (u, 6 - v),
|u, v| (6 - u, 6 - v),
|u, v| (v, u),
|u, v| (6 - v, u),
|u, v| (v, 6 - u),
|u, v| (6 - v, 6 - u),
];
let corners = [(0, 0), (6, 0), (0, 6), (6, 6)];
let idx = |c: (i64, i64)| corners.iter().position(|&x| x == c).unwrap();
let m =
maps.iter().find(|m| (0..4).all(|c| canon_marks[c] == marks[idx(m(corners[c].0, corners[c].1))])).expect("a face symmetry for the marks");
canon.iter().map(|q| sorted(q.map(|(u, v)| m(u, v)).map(|(u, v)| lift(u, v)))).collect()
}
fn face_corner_marks(axis: usize, value: i64, mask: u8) -> [bool; 4] {
let (ua, va) = match axis {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
[(0, 0), (6, 0), (0, 6), (6, 6)].map(|(u, v)| {
let mut p = [0; 3];
p[axis] = value;
p[ua] = u;
p[va] = v;
mask & (1 << corner_index(p)) != 0
})
}
/// Every template under every symmetry: valid elements, the leaf's volume, closed (each inner
/// face shared by two elements) and, on each leaf face, exactly the face template of that
/// face's marks.
#[test]
fn templates_are_valid_closed_and_conform_to_their_face_marks() {
let syms = symmetries();
for pattern in [Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face, Pattern::Full] {
for sym in &syms {
let mask = map_mask(sym, canonical_mask(pattern));
assert_eq!(classify(mask), Some(pattern));
let hexes: Vec<Hex> = template(pattern)
.into_iter()
.map(|h| {
let h = h.map(|p| sym.apply(p));
if corner0_orientation(&h) < 0 { reversed(h) } else { h }
})
.collect();
let mut volume = 0.0;
let mut faces: HashMap<[Local; 4], usize> = HashMap::new();
for h in &hexes {
let xyz: Vec<[f64; 3]> = h.iter().map(|p| p.map(|v| v as f64)).collect();
let check = element_validity(ElementKind::Hex8, &xyz).unwrap();
assert_eq!(check.validity, Validity::Valid, "{pattern:?} {sym:?} {h:?}");
volume += check.volume;
for f in ElementKind::Hex8.faces() {
*faces.entry(sorted([h[f[0]], h[f[1]], h[f[2]], h[f[3]]])).or_default() += 1;
}
}
assert!((volume - 216.0).abs() < 1e-9, "{pattern:?}: volume {volume}");
let mut outer: HashMap<(usize, i64), Vec<[Local; 4]>> = HashMap::new();
for (q, n) in &faces {
match on_leaf_face(q) {
Some(f) => {
assert_eq!(*n, 1);
outer.entry(f).or_default().push(*q);
}
None => assert_eq!(*n, 2, "{pattern:?} {sym:?}: inner face {q:?} used {n} times"),
}
}
for axis in 0..3 {
for value in [0, 6] {
let mut got = outer.remove(&(axis, value)).unwrap_or_default();
let mut want = face_template(axis, value, face_corner_marks(axis, value, mask));
got.sort();
want.sort();
assert_eq!(got, want, "{pattern:?} {sym:?} face {axis}={value}");
}
}
}
}
}
#[test]
fn patterns_without_a_template_are_recognised() {
assert_eq!(classify(0b1001), None, "diagonal of a face");
assert_eq!(classify(0b1000_0001), None, "opposite corners");
assert_eq!(classify(0b0111), None, "three of a face");
assert_eq!(classify(0b0110_1001), None, "four not on a face");
assert_eq!(classify(0b0111_1111), None);
assert_eq!(classify(0b0011_0011), Some(Pattern::Face));
assert_eq!(classify(0b0101_0000), Some(Pattern::Edge));
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
use crate::octree::geom::tests::box_surface;
use crate::size::UniformSize;
#[test]
fn a_box_meshes_with_exact_volume_and_tagged_faces() {
let s = box_surface([0.0; 3], [10.0, 8.0, 6.0]);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!((r.volume - 480.0).abs() < 1e-9 * 480.0, "{r:?}");
assert!(r.min_scaled_jacobian > 0.3, "{r:?}");
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
assert_eq!(faces.len(), 6);
// Each boundary quad lies in the plane of its face.
for b in &m.boundary {
let hx = m.hexes[b.element as usize];
let q = topo::FACES[(b.face - 1) as usize].map(|i| m.nodes[hx[i] as usize]);
let (axis, value) = match b.brep_face {
0 => (2, 0.0),
1 => (2, 6.0),
2 => (1, 0.0),
3 => (1, 8.0),
4 => (0, 0.0),
_ => (0, 10.0),
};
assert!(q.iter().all(|p| (p[axis] - value).abs() < 1e-9), "{b:?} {q:?}");
}
}
/// A closed cylinder of radius `r` and height `h` on the z axis, `n` segments: faces 0 (bottom
/// cap), 1 (top cap), 2 (side).
pub(crate) fn cylinder_surface(r: f64, h: f64, n: usize) -> BoundarySurface {
let mut p = vec![[0.0, 0.0, 0.0], [0.0, 0.0, h]];
for z in [0.0, h] {
for i in 0..n {
let a = std::f64::consts::TAU * i as f64 / n as f64;
p.push([r * a.cos(), r * a.sin(), z]);
}
}
let (b, t) = (|i: usize| (2 + i % n) as u32, |i: usize| (2 + n + i % n) as u32);
let mut tris = Vec::new();
let mut face = Vec::new();
for i in 0..n {
tris.push([0, b(i + 1), b(i)]);
face.push(0);
tris.push([1, t(i), t(i + 1)]);
face.push(1);
tris.push([b(i), b(i + 1), t(i + 1)]);
tris.push([b(i), t(i + 1), t(i)]);
face.extend([2, 2]);
}
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
/// A UV sphere of radius `r`, one face.
pub(crate) fn sphere_surface(r: f64, n: usize) -> BoundarySurface {
let rings = n / 2;
let mut p = vec![[0.0, 0.0, -r], [0.0, 0.0, r]];
for j in 1..rings {
let th = std::f64::consts::PI * j as f64 / rings as f64 - std::f64::consts::FRAC_PI_2;
for i in 0..n {
let a = std::f64::consts::TAU * i as f64 / n as f64;
p.push([r * th.cos() * a.cos(), r * th.cos() * a.sin(), r * th.sin()]);
}
}
let id = |j: usize, i: usize| (2 + (j - 1) * n + i % n) as u32;
let mut tris = Vec::new();
for i in 0..n {
tris.push([0, id(1, i + 1), id(1, i)]);
tris.push([1, id(rings - 1, i), id(rings - 1, i + 1)]);
}
for j in 1..rings - 1 {
for i in 0..n {
tris.push([id(j, i), id(j, i + 1), id(j + 1, i + 1)]);
tris.push([id(j, i), id(j + 1, i + 1), id(j + 1, i)]);
}
}
let face = vec![0; tris.len()];
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
}
#[test]
fn a_cylinder_meshes_with_its_rims_captured() {
let s = cylinder_surface(5.0, 8.0, 64);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
assert_eq!(faces.len(), 3);
}
#[test]
fn a_sphere_meshes() {
let s = sphere_surface(5.0, 48);
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
let r = &m.report;
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
}
+404
View File
@@ -0,0 +1,404 @@
//! Hexahedral mesh topology for the boundary stages: boundary faces, the manifold clean-up of
//! the core, padding a boundary with a layer of hexahedra, and pillowing a shrink set.
//!
//! *Padding* puts one hexahedron on every boundary quad, between the quad and a copy of it, so
//! no hexahedron keeps more than one face on the boundary. *Pillowing* a shrink set `S` inserts
//! a layer of hexahedra along the faces between `S` and the rest of the mesh; the layer reaches
//! the boundary along the loop where `S`'s boundary faces meet the others' (S. A. Mitchell,
//! T. J. Tautges, *Pillowing doublets: refining a mesh to ensure that faces share at most one
//! edge*, IMR 1995; Gao, Shen, Panozzo 2019, §3.4 use the same insertion around features).
use std::collections::{BTreeMap, HashMap};
use crate::{Error, Result};
/// The six faces of a hex8 as local node positions, right-hand normals outward (the model's
/// `ElementKind::Hex8.faces()` order).
pub(crate) const FACES: [[usize; 4]; 6] = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
/// A face key: the vertices sorted.
pub(crate) fn key(q: [u32; 4]) -> [u32; 4] {
let mut k = q;
k.sort_unstable();
k
}
/// The root of `i` in the union-find forest `c`, compressing the path from `i`.
pub(crate) fn find(c: &mut [usize], i: usize) -> usize {
let mut r = i;
while let Some(&p) = c.get(r).filter(|&&p| p != r) {
r = p;
}
if let Some(x) = c.get_mut(i) {
*x = r;
}
r
}
/// A face of an element: (hexahedron, local face 0..6).
pub(crate) type FaceRef = (u32, u8);
/// The vertices of a local face, outward.
pub(crate) fn face(h: &[u32; 8], f: u8) -> [u32; 4] {
FACES.get(f as usize).map_or([0; 4], |l| l.map(|i| h[i]))
}
/// Every face's elements, keyed by sorted vertices (deterministic map).
pub(crate) fn face_map(hexes: &[[u32; 8]]) -> BTreeMap<[u32; 4], Vec<FaceRef>> {
let mut m: BTreeMap<[u32; 4], Vec<FaceRef>> = BTreeMap::new();
for (i, h) in hexes.iter().enumerate() {
for f in 0..6u8 {
m.entry(key(face(h, f))).or_default().push((i as u32, f));
}
}
m
}
/// The boundary faces (on one element only), outward, in face-map order.
pub(crate) fn boundary(hexes: &[[u32; 8]]) -> Vec<(FaceRef, [u32; 4])> {
face_map(hexes)
.into_values()
.filter(|v| v.len() == 1)
.filter_map(|v| v.first().copied())
.map(|(h, f)| ((h, f), hexes.get(h as usize).map_or([0; 4], |x| face(x, f))))
.collect()
}
/// Edges of a quad list with the quads using each (ascending vertex pair → quad indices).
pub(crate) fn quad_edges(quads: &[[u32; 4]]) -> BTreeMap<(u32, u32), Vec<u32>> {
let mut m: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
for k in 0..4 {
let (a, b) = (q[k], q[(k + 1) % 4]);
m.entry((a.min(b), a.max(b))).or_default().push(i as u32);
}
}
m
}
/// The hexahedra to drop so that the boundary of `keep` is a 2-manifold: elements at edges with
/// four boundary faces (elements touching along an edge only) and at vertices whose boundary
/// faces form more than one fan. Returns the indices to drop (empty when manifold).
pub(crate) fn nonmanifold(hexes: &[[u32; 8]], keep: &[bool]) -> Vec<u32> {
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
let b = boundary(&sub);
let quads: Vec<[u32; 4]> = b.iter().map(|(_, q)| *q).collect();
let mut bad_vertices: Vec<u32> = Vec::new();
for ((a, c), qs) in quad_edges(&quads) {
if qs.len() != 2 {
bad_vertices.extend([a, c]);
}
}
// Fans: around each vertex, boundary quads connected through shared edges at the vertex.
let mut at: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for (i, q) in quads.iter().enumerate() {
for &v in q {
at.entry(v).or_default().push(i as u32);
}
}
for (v, qs) in &at {
let mut comp: Vec<usize> = (0..qs.len()).collect();
for i in 0..qs.len() {
for j in i + 1..qs.len() {
let (qa, qb) = (quads[qs[i] as usize], quads[qs[j] as usize]);
// Share an edge through v: another common vertex adjacent to v in both.
let nb = |q: [u32; 4]| {
let p = q.iter().position(|&x| x == *v).unwrap_or(0);
[q[(p + 1) % 4], q[(p + 3) % 4]]
};
if nb(qa).iter().any(|x| nb(qb).contains(x)) {
let (ri, rj) = (find(&mut comp, i), find(&mut comp, j));
comp[ri] = rj;
}
}
}
let roots = (0..qs.len()).filter(|&i| find(&mut comp, i) == i).count();
if roots > 1 {
bad_vertices.push(*v);
}
}
bad_vertices.sort_unstable();
bad_vertices.dedup();
if bad_vertices.is_empty() {
return Vec::new();
}
kept.into_iter().filter(|&i| hexes.get(i as usize).is_some_and(|h| h.iter().any(|v| bad_vertices.binary_search(v).is_ok()))).collect()
}
/// Pad: a hexahedron on each listed boundary face (outward), between it and a copy; the copies
/// of the faces' vertices are new points (appended, initially at the original positions).
/// Returns the copy of each original vertex.
pub(crate) fn pad(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, faces: &[[u32; 4]]) -> Result<BTreeMap<u32, u32>> {
let mut copy: BTreeMap<u32, u32> = BTreeMap::new();
for q in faces {
for &v in q {
if let std::collections::btree_map::Entry::Vacant(slot) = copy.entry(v) {
let n = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
slot.insert(n);
}
}
}
for q in faces {
let c = q.map(|v| copy.get(&v).copied().unwrap_or(v));
hexes.push([q[0], q[1], q[2], q[3], c[0], c[1], c[2], c[3]]);
}
Ok(copy)
}
/// One vertex copy made by [`pillow`]: the original, the copy, and the shrink-set elements
/// around the original that now use the copy.
#[derive(Debug, Clone)]
pub(crate) struct PillowCopy {
pub original: u32,
pub copy: u32,
pub elements: Vec<u32>,
}
/// Pillow the shrink set `inside` (one flag per element): a new layer of hexahedra on every face
/// between an element of the set and one outside it. Vertices of those faces get one copy per
/// group of set elements around them (connected through faces inside the set), which the set's
/// elements now use; the new layer joins the originals to the copies. Faces on the boundary are
/// not pillowed: where the layer meets the boundary, the set's boundary faces move to the copies,
/// so the boundary gains a ring of quads along the loop. New points are appended at the
/// originals' positions. Returns the copies made.
pub(crate) fn pillow(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, inside: &[bool]) -> Result<Vec<PillowCopy>> {
let fm = face_map(hexes);
let in_set = |h: u32| inside.get(h as usize).copied().unwrap_or(false);
// Faces to pillow, oriented from the outside element into the set.
let mut sheet: Vec<(u32, [u32; 4])> = Vec::new();
for refs in fm.values() {
if let [(a, fa), (b, fb)] = refs.as_slice() {
match (in_set(*a), in_set(*b)) {
(true, false) => sheet.push((*a, hexes.get(*a as usize).map_or([0; 4], |h| face(h, *fa)))),
(false, true) => sheet.push((*b, hexes.get(*b as usize).map_or([0; 4], |h| face(h, *fb)))),
_ => {}
}
}
}
if sheet.is_empty() {
return Ok(Vec::new());
}
let mut on_sheet: Vec<u32> = sheet.iter().flat_map(|(_, q)| *q).collect();
on_sheet.sort_unstable();
on_sheet.dedup();
// Set elements around each sheet vertex.
let mut around: HashMap<u32, Vec<u32>> = HashMap::new();
for (i, h) in hexes.iter().enumerate() {
if !in_set(i as u32) {
continue;
}
for &v in h {
if on_sheet.binary_search(&v).is_ok() {
let e = around.entry(v).or_default();
if e.last() != Some(&(i as u32)) {
e.push(i as u32);
}
}
}
}
let mut copies: Vec<PillowCopy> = Vec::new();
// (vertex, element) → copy.
let mut copy_of: HashMap<(u32, u32), u32> = HashMap::new();
for &v in &on_sheet {
let mut els = around.get(&v).cloned().unwrap_or_default();
els.sort_unstable();
els.dedup();
// Group by faces inside the set that contain v.
let mut group: Vec<usize> = (0..els.len()).collect();
for i in 0..els.len() {
for j in i + 1..els.len() {
let (Some(hi), Some(hj)) = (hexes.get(els[i] as usize), hexes.get(els[j] as usize)) else { continue };
let shares = (0..6u8).any(|f| {
let q = face(hi, f);
q.contains(&v) && (0..6u8).any(|g| key(face(hj, g)) == key(q))
});
if shares {
let (a, b) = (find(&mut group, i), find(&mut group, j));
group[a] = b;
}
}
}
// Per group: its copy and that copy's index in `copies`.
let mut made: BTreeMap<usize, (u32, usize)> = BTreeMap::new();
for (i, &e) in els.iter().enumerate() {
let r = find(&mut group, i);
let (c, at) = match made.get(&r) {
Some(&m) => m,
None => {
let c = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
copies.push(PillowCopy { original: v, copy: c, elements: Vec::new() });
made.insert(r, (c, copies.len() - 1));
(c, copies.len() - 1)
}
};
copy_of.insert((v, e), c);
if let Some(pc) = copies.get_mut(at) {
pc.elements.push(e);
}
}
}
// The new layer: original face (seen from outside, normal into the set) below, copies above.
let mut layer: Vec<[u32; 8]> = Vec::with_capacity(sheet.len());
for (h, q) in &sheet {
let r = [q[0], q[3], q[2], q[1]];
let c = r.map(|v| copy_of.get(&(v, *h)).copied().unwrap_or(v));
layer.push([r[0], r[1], r[2], r[3], c[0], c[1], c[2], c[3]]);
}
for (i, h) in hexes.iter_mut().enumerate() {
if in_set(i as u32) {
for v in h.iter_mut() {
if let Some(&c) = copy_of.get(&(*v, i as u32)) {
*v = c;
}
}
}
}
hexes.extend(layer);
Ok(copies)
}
/// Hexahedra to drop so that every vertex of the boundary of `keep` can be pushed outward: the
/// normals of its boundary quads must lie strictly inside one half-space (their mean has a
/// positive product with each, at least `margin`). Where this fails, padding from the vertex
/// to the part's surface inverts elements, so for each such vertex the kept element at it with
/// the most boundary faces is dropped (the buffer-zone rule of H. Tong, E. Halilaj, Y. J. Zhang,
/// *HybridOctree_Hex*, arXiv 2401.05984, 2024, §2.3, in a mean-normal form).
pub(crate) fn hidden_vertices(hexes: &[[u32; 8]], keep: &[bool], xyz: &dyn Fn(u32) -> [f64; 3], margin: f64) -> Vec<u32> {
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
let b = boundary(&sub);
let mut normals: BTreeMap<u32, Vec<[f64; 3]>> = BTreeMap::new();
let mut faces_of: BTreeMap<u32, usize> = BTreeMap::new();
for ((h, _), q) in &b {
let p = q.map(xyz);
let d1 = [0, 1, 2].map(|k| p[2][k] - p[0][k]);
let d2 = [0, 1, 2].map(|k| p[3][k] - p[1][k]);
let n = [d1[1] * d2[2] - d1[2] * d2[1], d1[2] * d2[0] - d1[0] * d2[2], d1[0] * d2[1] - d1[1] * d2[0]];
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if l > 0.0 {
for &v in q {
normals.entry(v).or_default().push(n.map(|c| c / l));
}
}
*faces_of.entry(*h).or_default() += 1;
}
let mut drop: Vec<u32> = Vec::new();
for (v, ns) in &normals {
let mut m = [0.0; 3];
for n in ns {
for k in 0..3 {
m[k] += n[k];
}
}
let l = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
let ok = l > 0.0 && ns.iter().all(|n| (n[0] * m[0] + n[1] * m[1] + n[2] * m[2]) / l > margin);
if ok {
continue;
}
// The kept element at v with the most boundary faces (lowest index on ties).
let best = sub
.iter()
.enumerate()
.filter(|(_, h)| h.contains(v))
.max_by(|a, b| faces_of.get(&(a.0 as u32)).unwrap_or(&0).cmp(faces_of.get(&(b.0 as u32)).unwrap_or(&0)).then(b.0.cmp(&a.0)))
.and_then(|(i, _)| kept.get(i).copied());
if let Some(e) = best {
drop.push(e);
}
}
drop.sort_unstable();
drop.dedup();
drop
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn faces_follow_the_model_hex8_face_order() {
let model = fieldcraft_model::ElementKind::Hex8.faces();
assert_eq!(model.len(), FACES.len());
for (m, f) in model.iter().zip(FACES) {
assert_eq!(*m, &f[..]);
}
}
/// An `n × n × n` block of unit cubes.
pub(crate) fn block(n: u32) -> (Vec<[f64; 3]>, Vec<[u32; 8]>) {
let id = |x: u32, y: u32, z: u32| x + (n + 1) * (y + (n + 1) * z);
let mut p = Vec::new();
for z in 0..=n {
for y in 0..=n {
for x in 0..=n {
p.push([x as f64, y as f64, z as f64]);
}
}
}
let mut h = Vec::new();
for z in 0..n {
for y in 0..n {
for x in 0..n {
h.push([
id(x, y, z),
id(x + 1, y, z),
id(x + 1, y + 1, z),
id(x, y + 1, z),
id(x, y, z + 1),
id(x + 1, y, z + 1),
id(x + 1, y + 1, z + 1),
id(x, y + 1, z + 1),
]);
}
}
}
(p, h)
}
fn closed(hexes: &[[u32; 8]]) {
for (k, v) in face_map(hexes) {
assert!(v.len() <= 2, "face {k:?} on {} elements", v.len());
}
let b = boundary(hexes);
for (_, qs) in quad_edges(&b.iter().map(|x| x.1).collect::<Vec<_>>()) {
assert_eq!(qs.len(), 2);
}
}
#[test]
fn padding_and_pillowing_keep_a_closed_conforming_mesh() {
let (mut p, mut h) = block(3);
let b: Vec<[u32; 4]> = boundary(&h).into_iter().map(|x| x.1).collect();
assert_eq!(b.len(), 54);
let copy = pad(&mut p, &mut h, &b).unwrap();
assert_eq!(copy.len(), 56);
assert_eq!(h.len(), 27 + 54);
closed(&h);
// Pillow the padding hexes on the face z = 0 (outer faces down).
let pad_start = 27;
let inside: Vec<bool> = (0..h.len()).map(|i| i >= pad_start && (0..4).all(|k| p[h[i][k] as usize][2] == 0.0)).collect();
assert_eq!(inside.iter().filter(|x| **x).count(), 9);
let before = h.len();
let copies = pillow(&mut p, &mut h, &inside).unwrap();
closed(&h);
// Sheet: the 9 inner faces plus the 12 side faces to the neighbouring padding.
assert_eq!(h.len(), before + 9 + 12);
// The boundary gains the ring: 54 + 12 quads.
assert_eq!(boundary(&h).len(), 54 + 12);
assert!(!copies.is_empty());
}
#[test]
fn nonmanifold_edges_and_vertices_are_found() {
let (_, h) = block(2);
// Two cubes touching along an edge.
let keep = |ids: &[usize]| (0..8).map(|i| ids.contains(&i)).collect::<Vec<bool>>();
assert!(!nonmanifold(&h, &keep(&[0, 3])).is_empty(), "edge contact");
assert!(!nonmanifold(&h, &keep(&[0, 7])).is_empty(), "vertex contact");
assert!(nonmanifold(&h, &keep(&[0, 1, 3])).is_empty());
assert!(nonmanifold(&h, &keep(&[0, 1, 2, 3, 4, 5, 6, 7])).is_empty());
}
}