engine: mesh.hex.octree command

Replaces the mesh with the octree all-hex mesh of a body, an imported surface or every body
of the open design, and tags boundary element faces with their B-rep faces so sets by
geometry resolve. Size, tolerance, refinement depth, element cap, attempts and optimiser
sweeps are validated and capped; the element cap defaults to the session limit
(`Limits::max_hex_elements`), which the hostile-params test keeps small. Body input and face
tagging are shared with mesh.hex.generate. The catalogue's Automatic Hex entry is ready.

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 3587b0de74
commit 7d2696057f
8 files changed
+221 -21

No files matched your search

+18 -12
View File
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{GeometryRef, Model};
@@ -28,13 +28,13 @@ const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
/// One body to mesh.
struct Input<'a> {
name: String,
mesh: Cow<'a, SurfaceMesh>,
/// One body to mesh (also the input of `mesh.hex.octree`).
pub(super) struct Input<'a> {
pub(super) name: String,
pub(super) mesh: Cow<'a, SurfaceMesh>,
surfaces: Vec<Option<Surface>>,
face_names: Vec<String>,
diagonal: f64,
pub(super) diagonal: f64,
}
fn analytic(s: FaceSurface) -> Surface {
@@ -47,7 +47,7 @@ fn analytic(s: FaceSurface) -> Surface {
}
}
fn number(p: &Value, key: &str) -> Result<Option<f64>> {
pub(super) fn number(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(v) => v
@@ -73,7 +73,7 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
}
fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
let tolerance = number(p, "tolerance")?;
match (p.get("body"), p.get("surface")) {
(Some(b), None) => {
@@ -220,7 +220,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
model.set_element(label(e)?, m.kind, nodes)?;
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
}
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), m, 1)?;
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?;
s.model = model;
let blocks: Vec<Value> =
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
@@ -318,7 +318,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, m, first_element)?;
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
report.push(json!({
"body": b.name,
"method": m.report.method.name(),
@@ -338,9 +338,15 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
/// Tag every boundary element face with the B-rep face it lies on, of the body
/// (`bodies[cell(element)]`) its element meshes.
fn tag_faces(model: &mut Model, bodies: &[Input<'_>], cell: impl Fn(u32) -> usize, m: &HexMesh, first_element: u32) -> Result<()> {
pub(super) fn tag_faces(
model: &mut Model,
bodies: &[Input<'_>],
cell: impl Fn(u32) -> usize,
boundary: &[BoundaryFace],
first_element: u32,
) -> Result<()> {
let mut tags: std::collections::BTreeMap<(usize, u32), u32> = std::collections::BTreeMap::new();
for f in &m.boundary {
for f in boundary {
let c = cell(f.element);
let tag = match tags.get(&(c, f.brep_face)) {
Some(&t) => t,
+1 -1
View File
@@ -93,7 +93,7 @@ const TET_MAX_STEINER: u64 = 10_000_000;
const TET_MAX_TETS: u64 = 50_000_000;
/// An optional whole-number parameter in `range`.
fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
let v = match p.get(key) {
None => default,
Some(v) => v
+2
View File
@@ -9,6 +9,7 @@ mod geometry;
mod hex;
pub(crate) mod mesh;
mod model;
pub(crate) mod octree;
mod results;
pub(crate) mod solve;
mod tags;
@@ -36,6 +37,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::SURFACE_CHECK,
mesh::PLANAR,
mesh::TET_GENERATE,
octree::HEX_OCTREE,
block::BLOCK,
hex::HEX_GENERATE,
hex::HEX_SWEEP,
+177
View File
@@ -0,0 +1,177 @@
//! Unstructured all-hexahedral meshing (`mesh.hex.octree`) of the open design's bodies or of
//! imported surfaces.
use fieldcraft_mesh::octree::{OctreeHexMesh, OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::hex::{Input, inputs, number, tag_faces};
use super::mesh::optional_count;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_OCTREE: CommandSpec = CommandSpec {
id: "mesh.hex.octree",
label: "Octree Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
kind: CommandKind::Model,
run: hex_octree,
};
/// Smallest element size, relative to the bounding-box diagonal (the grid's cell count grows
/// with the cube of its inverse).
const MIN_RELATIVE_SIZE: f64 = 1e-3;
/// Default element size, relative to the bounding-box diagonal.
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// Caps on the options.
const MAX_DEPTH: u64 = 5;
const MAX_ATTEMPTS: u64 = 8;
const MAX_SWEEPS: u64 = 10_000;
const MAX_HEX_ELEMENTS: u64 = 10_000_000;
/// The session default of `max_elements` ([`crate::Limits::max_hex_elements`]).
pub(crate) const DEFAULT_MAX_ELEMENTS: usize = 2_000_000;
fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
let size = number(p, "size")?;
let defaults = OctreeOptions::default();
let options = OctreeOptions {
max_depth: u8::try_from(optional_count(p, "max_depth", u64::from(defaults.max_depth), 0..=MAX_DEPTH)?).unwrap_or(defaults.max_depth),
max_elements: optional_count(p, "max_elements", s.limits.max_hex_elements as u64, 1..=MAX_HEX_ELEMENTS)?.min(MAX_ELEMENTS),
attempts: optional_count(p, "attempts", defaults.attempts as u64, 1..=MAX_ATTEMPTS)?,
max_sweeps: optional_count(p, "max_sweeps", defaults.max_sweeps as u64, 0..=MAX_SWEEPS)?,
..defaults
};
let bodies = inputs(s, p)?;
let mut meshes: Vec<(Input, OctreeHexMesh)> = Vec::with_capacity(bodies.len());
let mut total = 0usize;
for b in bodies {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
total += mesh.hexes.len();
meshes.push((b, mesh));
}
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
let mut model = s.model.without_mesh();
let mut report = Vec::with_capacity(meshes.len());
let (mut node_label, mut element_label) = (1u32, 1u32);
for (b, m) in &meshes {
let first_node = node_label;
for xyz in &m.nodes {
model.set_node(node_label, *xyz)?;
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
}
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
let first_element = element_label;
for hx in &m.hexes {
model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
let r = &m.report;
report.push(json!({
"body": b.name,
"kind": "hex8",
"elements": m.hexes.len(),
"nodes": m.nodes.len(),
"boundary_faces": m.boundary.len(),
"quality": {"min_scaled_jacobian": r.min_scaled_jacobian, "mean_scaled_jacobian": r.mean_scaled_jacobian},
"volume": r.volume,
"surface_volume": r.surface_volume,
"grid": {"cells": r.grid_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
"features": {"corners": r.corners, "curves": r.curves, "patches": r.patches},
"attempts": r.attempts,
"retries": r.retries,
"sweeps": r.sweeps,
}));
}
s.model = model;
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use fieldcraft_geometry::open_design;
use fieldcraft_geometry::sample::{Plate, plate_design};
use serde_json::json;
use crate::Session;
fn opened() -> Session {
let plate = Plate::default();
Session { design: Some(Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap())), ..Session::default() }
}
#[test]
fn plate_with_a_hole_meshes_all_hex_and_face_sets_resolve() {
let mut s = opened();
let r = s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 5.0, "max_depth": 2})).unwrap();
let body = &r["bodies"][0];
let n = body["elements"].as_u64().unwrap() as usize;
assert!(n > 50, "{r}");
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.0, "{r}");
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, n)]);
let (v, sv) = (body["volume"].as_f64().unwrap(), body["surface_volume"].as_f64().unwrap());
assert!((v - sv).abs() < 0.02 * sv, "{r}");
// The certified quality report agrees.
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, n);
// Every boundary face is tagged: the skin of the body set; each named face resolves.
s.execute("model.set.create", &json!({"name": "skin", "geometry": [{"body": "Body1"}]})).unwrap();
let skin = s.execute("model.set.resolve", &json!({"name": "skin"})).unwrap();
assert_eq!(skin["faces"]["count"], body["boundary_faces"], "{skin}");
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
for f in bodies[0]["faces"].as_array().unwrap() {
let name = f["name"].as_str().unwrap();
s.execute("model.set.create", &json!({"name": "f", "geometry": [{"body": "Body1", "face": name}]})).unwrap();
let set = s.execute("model.set.resolve", &json!({"name": "f"})).unwrap();
assert!(set["faces"]["count"].as_u64().unwrap() > 0, "face {name}: {set}");
s.execute("model.set.delete", &json!({"name": "f"})).unwrap();
}
}
#[test]
fn hostile_params_are_errors_and_change_nothing() {
let mut s = opened();
let before = s.model.clone();
for p in [
json!({"body": "Body1", "size": 0}),
json!({"body": "Body1", "size": -1}),
json!({"body": "Body1", "size": 1e-300}),
json!({"body": "Body1", "size": "big"}),
json!({"body": "Body1", "tolerance": 1e-300}),
json!({"body": "Body1", "tolerance": -1}),
json!({"body": "Body1", "max_depth": 9}),
json!({"body": "Body1", "max_depth": -1}),
json!({"body": "Body1", "max_elements": 0}),
json!({"body": "Body1", "max_elements": 1e300}),
json!({"body": "Body1", "attempts": 0}),
json!({"body": "Body1", "attempts": 100}),
json!({"body": "Body1", "max_sweeps": "many"}),
json!({"body": "Body1", "size": 5.0, "max_depth": 2, "max_elements": 10}),
json!({"body": "Nope"}),
json!({"body": 7}),
json!({"surface": 0}),
json!({"surface": -1}),
json!({"body": "Body1", "surface": 0}),
] {
let r = s.execute("mesh.hex.octree", &p);
assert!(r.is_err(), "{p}: {r:?}");
assert_eq!(s.model, before, "{p}");
}
assert!(Session::default().execute("mesh.hex.octree", &json!({})).is_err());
}
}