engine: bracket-hex sample, the partitioned bracket meshed all-hex and solved

file.sample bracket-hex: the corpus bracket split into 30 SolidCraft cells,
meshed by mesh.hex.blocks as one conforming hex20 mesh of 3 mm, clamped at
the upright and loaded at the foot. The sample's script finds the clamp and
load faces in every cell that has them. Its test matches the tip deflection
of a fine tet10 solution within 1 % and checks the clamp carries the load.

Closes #166

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 15:55:22 -04:00
1 parent f5a0a1289a
commit 0b7f3021d9
5 files changed
+125 -12

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ is committed and listed here with its exports.
| docs/media/examples/drop-test.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved drop-test sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/rubber-seal.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved rubber-seal sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/heated-bracket.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved heated-bracket sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/bracket-hex.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved bracket-hex sample (docs/examples.md) | MIT OR Apache-2.0 |
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+16 -2
View File
@@ -808,8 +808,22 @@ pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).req()];
pub const RECOVERY_LIST: &[Field] = &[path("dir", "Folder", false).hint("the recovery folder")];
pub const RECOVER: &[Field] = &[text("id", "Autosave").req(), path("dir", "Folder", false).hint("the recovery folder")];
pub const RECOVERY_DISCARD: &[Field] = &[text("id", "Autosave"), flag("all", "All"), path("dir", "Folder", false).hint("the recovery folder")];
pub const SAMPLE: &[Field] =
&[choice("name", "Sample", &[("cantilever", "Cantilever"), ("impact", "Impact"), ("parts", "Parts"), ("drop-test", "Drop test"), ("rubber-seal", "Rubber seal"), ("flow-past", "Flow past a bracket"), ("heated-bracket", "Heated bracket")]).req().def("\"cantilever\"")];
pub const SAMPLE: &[Field] = &[choice(
"name",
"Sample",
&[
("cantilever", "Cantilever"),
("impact", "Impact"),
("parts", "Parts"),
("drop-test", "Drop test"),
("rubber-seal", "Rubber seal"),
("flow-past", "Flow past a bracket"),
("heated-bracket", "Heated bracket"),
("bracket-hex", "Hex-meshed bracket"),
],
)
.req()
.def("\"cantilever\"")];
pub const COUNT: &[Field] = &[int("count", "Count", 1, 1_000_000).def("1")];
pub const DECK: &[Field] = &[
+88 -10
View File
@@ -82,6 +82,13 @@ pub const SAMPLES: &[Sample] = &[
design: Some(FILLETED_BRACKET),
script: |_| heated_bracket(),
},
Sample {
name: "bracket-hex",
label: "Hex-Meshed Bracket",
about: "The filleted bracket split into 30 cells and meshed all-hex (hex20): clamped, loaded, von Mises",
design: Some(PARTITIONED_BRACKET),
script: bracket_hex,
},
];
/// The sample called `name`.
@@ -324,8 +331,16 @@ pub fn rubber_seal() -> Value {
for quarter in 0..4u8 {
let (a0, a1) = (f64::from(quarter) * std::f64::consts::FRAC_PI_2, f64::from(quarter + 1) * std::f64::consts::FRAC_PI_2);
let at = |r: f64, a: f64, z: f64| [r * a.cos(), r * a.sin(), z];
let corners =
[at(inner, a0, 0.0), at(outer, a0, 0.0), at(outer, a1, 0.0), at(inner, a1, 0.0), at(inner, a0, height), at(outer, a0, height), at(outer, a1, height), at(inner, a1, height)];
let corners = [
at(inner, a0, 0.0),
at(outer, a0, 0.0),
at(outer, a1, 0.0),
at(inner, a1, 0.0),
at(inner, a0, height),
at(outer, a0, height),
at(outer, a1, height),
at(inner, a1, height),
];
let arc = |a: u8, b: u8, z: f64| json!({"corners": [a, b], "arc": {"center": [0, 0, z]}});
steps.push(json!({"command": "mesh.block", "params": {
"body": "Seal", "kind": "hex8", "corners": corners, "divisions": [5, 10, 5],
@@ -393,10 +408,11 @@ pub const STEEL_EXPANSION: f64 = 1.2e-5;
/// k in mW/(mm K), h in mW/(mm² K).
pub fn heated_bracket() -> Value {
let face = |f: &str| json!({"body": "Bracket", "face": f});
let skin: Vec<Value> = ["F2:start", "F2:top", "F2:side:l1", "F2:side:l2", "F2:side:l3", "F2:side:l4", "F2:side:l6", "F3:blend:F2:side:l3|F2:side:l4"]
.into_iter()
.map(face)
.collect();
let skin: Vec<Value> =
["F2:start", "F2:top", "F2:side:l1", "F2:side:l2", "F2:side:l3", "F2:side:l4", "F2:side:l6", "F3:blend:F2:side:l3|F2:side:l4"]
.into_iter()
.map(face)
.collect();
let holes: Vec<Value> = ["F4:bore:side:0", "F4:bore:side:1", "F5:bore:side:0", "F5:bore:side:1"].into_iter().map(face).collect();
json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
@@ -415,6 +431,33 @@ pub fn heated_bracket() -> Value {
])
}
/// The corpus filleted bracket split into 30 cells (`tests/corpus/filleted-bracket-blocks`),
/// each a mapped block: the part `mesh.hex.blocks` meshes all-hex.
pub const PARTITIONED_BRACKET: &str = include_str!("../../../tests/corpus/filleted-bracket-blocks/recipe.json");
/// [`PARTITIONED_BRACKET`] meshed all-hex as one conforming mesh of hex20 elements of 3 mm, in
/// steel: the upright's end (`F2:side:l5` in every cell that has it, set `top`) clamped, 1 kN down
/// (−y) on the foot's end (`F2:side:l2`, set `tip`). Step `Load` (linear static). `bodies` are the
/// design's cells.
pub fn bracket_hex(bodies: &[Shape]) -> Value {
let faces = |name: &str| -> Vec<Value> {
bodies.iter().filter(|b| b.faces().iter().any(|f| f.name == name)).map(|b| json!({"body": b.name, "face": name})).collect()
};
let cells: Vec<Value> = bodies.iter().map(|b| json!({"body": b.name})).collect();
json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "mesh.hex.blocks", "params": {"size": 3, "order": 2}},
{"command": "model.set.create", "params": {"name": "bracket", "geometry": cells}},
{"command": "model.set.create", "params": {"name": "top", "geometry": faces("F2:side:l5")}},
{"command": "model.set.create", "params": {"name": "tip", "geometry": faces("F2:side:l2")}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}},
{"command": "model.section.create", "params": {"name": "Bracket", "set": "bracket", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "top"}}},
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, -1000, 0]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}},
])
}
/// Six times the signed volume of the tetrahedron `p[0..4]` (positive when p3 lies on the side
/// p0 → p1 → p2 turns counter-clockwise around).
fn signed_volume(p: &[[f64; 3]]) -> f64 {
@@ -515,14 +558,30 @@ mod tests {
let node = |s: &Session, xyz: [f64; 3]| -> u32 {
*s.model.nodes().iter().find(|(_, n)| (0..3).all(|k| (n.xyz[k] - xyz[k]).abs() < 1e-9)).unwrap().0
};
let held = [("hold_y", [inner, 0.0, 0.0], [None, Some(0.0), None]), ("hold_x", [0.0, inner, 0.0], [Some(0.0), None, None]), ("hold_y2", [-inner, 0.0, 0.0], [None, Some(0.0), None])];
let held = [
("hold_y", [inner, 0.0, 0.0], [None, Some(0.0), None]),
("hold_x", [0.0, inner, 0.0], [Some(0.0), None, None]),
("hold_y2", [-inner, 0.0, 0.0], [None, Some(0.0), None]),
];
for (name, at, [ux, uy, _]) in held {
let n = node(&s, at);
s.execute("model.set.create", &json!({"name": name, "nodes": [n]})).unwrap();
s.execute("model.boundary_condition.create", &json!({"name": name, "type": "displacement", "target": {"set": name}, "values": [ux, uy, null, null, null, null]})).unwrap();
s.execute(
"model.boundary_condition.create",
&json!({"name": name, "type": "displacement", "target": {"set": name}, "values": [ux, uy, null, null, null, null]}),
)
.unwrap();
}
s.execute("model.boundary_condition.edit", &json!({"name": "Base", "type": "displacement", "target": {"set": "bottom"}, "values": [null, null, 0, null, null, null]})).unwrap();
s.execute("model.boundary_condition.edit", &json!({"name": "Press", "type": "displacement", "target": {"set": "top"}, "values": [null, null, -SEAL_SQUEEZE, null, null, null]})).unwrap();
s.execute(
"model.boundary_condition.edit",
&json!({"name": "Base", "type": "displacement", "target": {"set": "bottom"}, "values": [null, null, 0, null, null, null]}),
)
.unwrap();
s.execute(
"model.boundary_condition.edit",
&json!({"name": "Press", "type": "displacement", "target": {"set": "top"}, "values": [null, null, -SEAL_SQUEEZE, null, null, null]}),
)
.unwrap();
s.execute("model.step.edit", &json!({"name": "Squeeze", "boundary_conditions": ["Base", "Press", "hold_y", "hold_x", "hold_y2"]})).unwrap();
let free = squeeze(&mut s);
assert!((free - closed).abs() < 0.03 * closed.abs(), "frictionless {free} N against {closed} N");
@@ -586,6 +645,25 @@ mod tests {
}
}
/// The hex20 bracket's tip deflection agrees with a fine tet10 solution of the unpartitioned
/// part (0.5093 mm, crates/testkit/tests/bracket_blocks.rs) within 1 %, its mesh conforms
/// across all 30 cells, and the load reaches the clamp.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_hex_bracket_bends_like_the_fine_tet_solution() {
let mut s = open("bracket-hex");
let elements = s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Hex20).count();
assert_eq!(elements, s.model.elements().len());
let r = s.execute("solve.run", &json!({})).unwrap();
let r = &r["steps"][0]["summary"];
let probe = s.execute("solve.probe", &json!({"step": "Load", "at": [[60.0, 4.0, 20.0]]})).unwrap();
let tip = probe["nodes"][0]["displacement"][1].as_f64().unwrap();
assert!((tip + 0.5093).abs() < 0.01 * 0.5093, "{tip}");
let reaction = s.execute("results.reactions", &json!({"step": "Load", "sets": ["top"]})).unwrap();
assert!((reaction["sets"][0]["force"][1].as_f64().unwrap() - 1000.0).abs() < 1e-6, "{reaction}");
assert!(r["max_von_mises"]["value"].as_f64().unwrap() > 200.0, "{r}");
}
#[test]
fn the_drop_test_opens_ready_to_solve() {
let s = open("drop-test");
+20
View File
@@ -91,3 +91,23 @@ evenly by ΔT = 100 K, and clamps the foot's hole alone: the bracket then grows
and a point moves α ΔT times its distance from the hole's axis. The upright's top moves −0.0432
mm along x (within 2 %); the foot's end, 16 mm from the clamped bore, which cannot grow, moves
0.0228 mm against 0.024 mm (within 6 %).
## Hex-meshed bracket (`bracket-hex`)
![Von Mises stress on the all-hex bracket, O-grids round both holes, peaking at the upright's hole](media/examples/bracket-hex.png)
The same filleted bracket split into 30 cells in SolidCraft
(`tests/corpus/filleted-bracket-blocks`: planar cuts through the legs and diagonal cuts round
the holes and the fillet, each cell a six-sided block), meshed by `mesh.hex.blocks` as one
conforming all-hex mesh: 5 778 hex20 elements of 3 mm, O-grids round the holes, graded through
the fillet, every shared face meshed once. Steel; the upright's end clamped, 1 kN down on the
foot's end. Linear static: about 2 s to open (the cells are rebuilt from the script) and 3 s to
solve.
**Look at:** the mesh (Prepare ▸ Mesh) — mapped blocks meeting face to face with no pyramids or
tets — and the von Mises contours on the deformed shape: the stress runs round the fillet and
peaks at 295 MPa at the upright's hole.
**Check:** the tip of the foot moves 0.509 mm down, within 1 % of a 48 016-element tet10
solution of the unpartitioned part (0.5093 mm, `crates/testkit/tests/bracket_blocks.rs`), and the
clamp carries the whole 1 kN.
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB