diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 29c341a..0c1527e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index d78f1b0..9bba9fc 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -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] = &[ diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index c032ee5..9da1f6b 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -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 = ["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 = + ["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 = ["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 { + bodies.iter().filter(|b| b.faces().iter().any(|f| f.name == name)).map(|b| json!({"body": b.name, "face": name})).collect() + }; + let cells: Vec = 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"); diff --git a/docs/examples.md b/docs/examples.md index b345e2a..983e2f2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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. diff --git a/docs/media/examples/bracket-hex.png b/docs/media/examples/bracket-hex.png new file mode 100644 index 0000000..8178eb0 Binary files /dev/null and b/docs/media/examples/bracket-hex.png differ