corpus: the filleted bracket partitioned into 30 blocks, meshed all-hex

filleted-bracket-blocks splits the bracket with SolidCraft's Split Body
into three slabs across its width, the fillet corner halved along its
diagonal and a four-cell O-grid round each hole; the corpus runner meshes
partitioned parts as one conforming block mesh. A heavy test clamps the
upright, loads the foot and checks hex8 and hex20 converge (hex20 tip
deflection within 0.1 % of a fine tet10 solution). Roadmap M5:
conforming interfaces done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 04:56:46 -04:00
1 parent 98d2f55407
commit 3ecb7c6883
7 files changed
+218 -27

No files matched your search

+1
View File
@@ -40,6 +40,7 @@ pub const CATEGORIES: &[(&str, &str)] = &[
("blocky", "axis-aligned blocks (polycube-friendly)"),
("organic", "free-form, lofted or revolved spline surfaces"),
("assembly", "several bodies placed by component transforms"),
("partitioned", "split into cells that each mesh as a mapped block (conforming multi-block hex)"),
("analytic", "closed-form geometry for solver verification"),
];
+53 -2
View File
@@ -15,7 +15,7 @@ use std::time::Instant;
use crate::corpus::{self, CATEGORIES, Part};
use fieldcraft_geometry::{Design, FaceSurface, Measure, SurfaceMesh};
use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::hex::{HexOptions, Surface, hex_mesh};
use fieldcraft_mesh::hex::{Cell, HexOptions, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
@@ -123,7 +123,8 @@ const REASON_CHARS: usize = 240;
/// Structured all-hex meshing (`fieldcraft_mesh::hex`): the method that meshed each body, the
/// element count and the smallest scaled Jacobian of the hex8 mesh and of the hex20 mesh (mid-edge
/// nodes on the geometry), or why no structured method fits.
/// nodes on the geometry), or why no structured method fits. A `partitioned` part's bodies are
/// the cells of one conforming mesh (`blocks`).
struct StructuredHex;
impl Stage for StructuredHex {
@@ -144,6 +145,12 @@ impl Stage for StructuredHex {
let size = UniformSize(HEX_RELATIVE_SIZE * diagonal);
let linear = HexOptions { chordal_tolerance: l.tolerance, ..HexOptions::default() };
let quadratic = HexOptions { quadratic: true, ..linear.clone() };
if l.part.categories.iter().any(|c| c == "partitioned") {
return blocks(l, &size, &linear, &quadratic).map(|mut cells| {
cells.push(format!("{:.0}", start.elapsed().as_secs_f64() * 1e3));
cells
});
}
let (mut methods, mut elements, mut sj8, mut sj20) = (Vec::new(), 0usize, f64::INFINITY, Ok(f64::INFINITY));
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let surface_options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
@@ -180,6 +187,39 @@ impl Stage for StructuredHex {
}
}
/// The bodies of a partitioned part as the cells of one conforming mesh: method, elements and
/// the hex8 and hex20 smallest scaled Jacobians.
fn blocks(l: &Loaded, size: &UniformSize, linear: &HexOptions, quadratic: &HexOptions) -> Result<Vec<String>, String> {
let mut surfaces = Vec::with_capacity(l.surfaces.len());
let mut faces: Vec<Vec<Option<Surface>>> = Vec::with_capacity(l.surfaces.len());
for (body, mesh) in l.design.bodies.iter().zip(&l.surfaces) {
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
surfaces.push(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options).map_err(|e| format!("{}: {e}", body.name))?);
faces.push(body.face_surfaces().map_err(|e| format!("{}: {e}", body.name))?.into_iter().map(|f| f.map(analytic)).collect());
}
let cells: Vec<Cell> =
l.design.bodies.iter().zip(&surfaces).zip(&faces).map(|((b, surface), faces)| Cell { name: &b.name, surface, faces }).collect();
let m = match hex_mesh_cells(&cells, size, linear) {
Ok(m) => m,
Err(e) => {
return Ok(vec![
format!("no: {}", e.to_string().chars().take(REASON_CHARS).collect::<String>()),
String::new(),
String::new(),
String::new(),
]);
}
};
let sj20 =
hex_mesh_cells(&cells, size, quadratic).map_or_else(|e| format!("error: {e}"), |q| format!("{:.3}", q.mesh.report.min_scaled_jacobian));
Ok(vec![
format!("blocks ({} cells, {} shared faces)", cells.len(), m.interfaces),
m.mesh.element_count().to_string(),
format!("{:.3}", m.mesh.report.min_scaled_jacobian),
sj20,
])
}
/// The geometry's analytic face surface in the mesher's terms.
pub fn analytic(s: FaceSurface) -> Surface {
match s {
@@ -330,6 +370,17 @@ mod tests {
}
}
/// The partitioned bracket's 30 cells mesh into one conforming hex mesh.
#[test]
fn partitioned_bracket_meshes_as_blocks() {
let part = corpus::read_recipe(&corpus::corpus_dir().join("filleted-bracket-blocks")).unwrap();
let (loaded, _) = load(&part).unwrap();
assert_eq!(loaded.design.bodies.len(), 30);
let cells = StructuredHex.run(&loaded).unwrap();
assert!(cells[0].starts_with("blocks (30 cells"), "{cells:?}");
assert!(cells[2].parse::<f64>().unwrap() > 0.5, "{cells:?}");
}
/// The constrained Delaunay tetrahedralization passes the gate on parts that exercise each
/// of its stages: an assembly whose rounding needs a few-ulp repair, cavities refilled by
/// gift-wrapping, and a holed plate.
+96
View File
@@ -0,0 +1,96 @@
//! The filleted bracket meshed all-hex by partitioning (`tests/corpus/filleted-bracket-blocks`):
//! the upright's end clamped, the foot's end pushed down by 1 kN (steel). The conforming hex8 and
//! hex20 meshes converge: the tip deflection, the von Mises stress at the middle of the fillet and
//! the peak (at the upright's hole) agree between hex20 at 3 mm and 2 mm, and hex8 approaches them.
//! For reference, a 48 016-element tet10 mesh of the unpartitioned part (curvature-refined, from
//! the tet quality pipeline) gives a tip deflection of 0.5093 mm, 133.2 MPa at the fillet and a
//! peak of 291.8 MPa; hex20 at 2 mm gives 0.5089 mm, 137.4 MPa and 297.0 MPa.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use fieldcraft_engine::Session;
use fieldcraft_testkit::corpus;
use serde_json::{Value, json};
fn open(part: &str) -> (Session, std::path::PathBuf) {
let parts = corpus::parts().unwrap();
let p = parts.iter().find(|p| p.name == part).unwrap();
let dir = std::env::temp_dir().join(format!("fieldcraft-bracket-{}-{part}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.solidcraft");
std::fs::write(&path, corpus::design_bytes(p).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
(s, dir)
}
/// Every (body, face) whose name is `face` (a face split into cells keeps its name in each).
fn faces_named(s: &mut Session, face: &str) -> Vec<Value> {
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
let mut out = Vec::new();
for b in bodies.as_array().unwrap() {
for f in b["faces"].as_array().unwrap() {
if f["name"] == face {
out.push(json!({"body": b["name"], "face": face}));
}
}
}
out
}
/// Clamp the upright's top (y = 50), push the foot's end (x = 60) down by 1 kN; tip deflection,
/// von Mises at the middle of the fillet and the largest von Mises.
fn analyse(s: &mut Session) -> (f64, f64, f64) {
let top = faces_named(s, "F2:side:l5");
let tip = faces_named(s, "F2:side:l2");
assert!(!top.is_empty() && !tip.is_empty());
for (name, geometry) in [
(
"all",
json!(
s.execute("geometry.bodies", &json!({})).unwrap().as_array().unwrap().iter().map(|b| json!({"body": b["name"]})).collect::<Vec<_>>()
),
),
("top", json!(top)),
("tip", json!(tip)),
] {
s.execute("model.set.create", &json!({"name": name, "geometry": geometry})).unwrap();
}
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
s.execute("model.section.create", &json!({"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
s.execute("model.boundary_condition.create", &json!({"name": "clamp", "type": "fixed", "target": {"set": "top"}})).unwrap();
s.execute("model.load.create", &json!({"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0.0, -1000.0, 0.0]})).unwrap();
s.execute("model.step.create", &json!({"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}))
.unwrap();
let r = s.execute("solve.static", &json!({})).unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Load", "at": [[60.0, 4.0, 20.0], [9.757359312880714, 9.757359312880714, 20.0]]})).unwrap();
let nodes = probe["nodes"].as_array().unwrap();
let tip = nodes[0]["displacement"][1].as_f64().unwrap();
let fillet = nodes[1]["von_mises"].as_f64().unwrap();
(tip, fillet, r["max_von_mises"]["value"].as_f64().unwrap())
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn partitioned_bracket_converges_with_hex8_and_hex20() {
let mut results = Vec::new();
for (order, size) in [(1, 2.0), (2, 3.0), (2, 2.0)] {
let (mut s, dir) = open("filleted-bracket-blocks");
let m = s.execute("mesh.hex.blocks", &json!({"size": size, "order": order})).unwrap();
assert_eq!(m["cells"], 30, "{m}");
assert!(m["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.6, "{m}");
let r = analyse(&mut s);
eprintln!("order {order}, size {size}: {} elements; tip {:.5} mm, fillet {:.2} MPa, peak {:.2} MPa", m["elements"], r.0, r.1, r.2);
results.push(r);
std::fs::remove_dir_all(dir).ok();
}
let close = |a: f64, b: f64, rel: f64| (a - b).abs() <= rel * b.abs();
let [hex8, coarse, fine] = [results[0], results[1], results[2]];
// The tip moves down; hex20 has converged on it, hex8 is within a few percent (stiffer).
assert!(fine.0 < 0.0);
assert!(close(coarse.0, fine.0, 0.005), "{coarse:?} {fine:?}");
assert!(close(hex8.0, fine.0, 0.04) && hex8.0.abs() < fine.0.abs(), "{hex8:?} {fine:?}");
// Stresses at the fillet and the peak.
assert!(close(coarse.1, fine.1, 0.06), "{coarse:?} {fine:?}");
assert!(close(coarse.2, fine.2, 0.02), "{coarse:?} {fine:?}");
assert!(close(hex8.1, fine.1, 0.15) && close(hex8.2, fine.2, 0.10), "{hex8:?} {fine:?}");
}