diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index be4bf66..17a4590 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,6 +18,7 @@ is committed and listed here with its exports. | docs/media/ibeam-analysis.mp4, docs/media/ibeam-analysis.gif | FieldCraft contributors | Original: the FieldCraft window captured during an MCP session, generated by `tools/demo-video/make.py` | MIT OR Apache-2.0 | | docs/media/og.svg, docs/media/og.png | FieldCraft contributors | Original social-preview image (1280 × 640): the application icon's cube layer (assets/app-icon/FieldCraft.icon/Assets/02-part.svg) beside the name and tagline set in Inter (OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 | | docs/media/bracket-partition.png, docs/media/bracket-hex.png, docs/media/bracket-stress.png, docs/media/setup-symbols.png | FieldCraft contributors | Original: FieldCraft renders (headless MCP screenshots and a window capture) of corpus parts and the cantilever sample | MIT OR Apache-2.0 | +| 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 | | 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 704c67f..ab13bad 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -809,7 +809,7 @@ pub const RECOVERY_LIST: &[Field] = &[path("dir", "Folder", false).hint("the rec 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")]).req().def("\"cantilever\"")]; + &[choice("name", "Sample", &[("cantilever", "Cantilever"), ("impact", "Impact"), ("parts", "Parts"), ("drop-test", "Drop test")]).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 8e7989d..86879e8 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -54,6 +54,13 @@ pub const SAMPLES: &[Sample] = &[ design: None, script: |_| impact(), }, + Sample { + name: "drop-test", + label: "Drop Test", + about: "An aluminium L-bracket dropped 2 m onto a tilted rigid floor: explicit dynamics, the strike and its stress waves", + design: Some(UPRIGHT_BRACKET), + script: |_| drop_test(), + }, ]; /// The sample called `name`. @@ -228,6 +235,59 @@ pub fn impact() -> Value { Value::Array(steps) } +/// An L-bracket standing on its foot, Z up, as a SolidCraft script: a 60 × 40 mm foot and a 50 mm +/// upright, both 8 mm thick, with an R6 fillet inside the bend (the corpus `filleted-bracket`'s +/// profile without its holes, sketched on XZ so it sweeps along y). Body `Bracket`; its faces +/// `F2:side:l1` (the foot's underside, z = 0), `l2` (the foot's end, x = 60), `l3` (the foot's top), +/// `l4` (the upright's inside, x = 8), `l5` (the upright's top, z = 50) and `l6` (its back, x = 0). +pub const UPRIGHT_BRACKET: &str = r#"{"commands": [ + {"command": "sketch.create", "params": {"plane": "XZ", "name": "Profile"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, -8], [8, -8], [8, -50], [0, -50]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}}, + {"command": "solid.fillet", "params": {"edges": [[8, 20, 8]], "radius": 6}} +]}"#; + +/// Aluminium (E = 70 GPa, ν = 0.33, ρ = 2.7 t/m³, yield 240 MPa with 700 MPa linear hardening), +/// in mm, t, s, N, MPa. +fn aluminium(name: &str) -> Value { + json!({"command": "model.material.create", "params": {"name": name, "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}, "density": 2.7e-9, + "plastic": {"type": "bilinear", "yield_stress": 240, "hardening_modulus": 700, "hardening": "isotropic"}}}) +} + +/// Standard gravity, mm/s². +pub const GRAVITY: f64 = 9810.0; +/// The drop test's height of fall, mm: the bracket meets the floor at √(2 g h) = 6.26 m/s. +pub const DROP_HEIGHT: f64 = 2000.0; +/// The drop test's floor tilt about y, degrees: the foot's end strikes first. +const DROP_TILT: f64 = 15.0; + +/// [`UPRIGHT_BRACKET`] in aluminium, hex8 elements swept 3 mm (one-point, stiffness hourglass +/// control), falling at √(2 g h) onto a rigid floor tilted 15° so the foot's end lands first, +/// 0.1 mm below that corner; gravity on. Step `Drop` (explicit dynamics, 1 ms: the strike, the +/// stress waves through the bend and the bracket pivoting onto its foot; 20 field frames, the +/// upright's top (set `top`) in the histories). +pub fn drop_test() -> Value { + let speed = (2.0 * GRAVITY * DROP_HEIGHT).sqrt(); + let tilt = DROP_TILT.to_radians(); + let step = json!({ + "type": "explicit_dynamic", "end_time": 1e-3, "output_interval": 5e-5, "history_interval": 5e-6, "history_nodes": "top", + "initial_velocities": [{"set": "bracket", "velocity": [0, 0, -speed]}], + "rigid_walls": [{"set": "bracket", "point": [60, 0, -0.1], "normal": [-tilt.sin(), 0, tilt.cos()]}], + "hourglass": {"type": "stiffness", "coefficient": 0.05} + }); + json!([ + {"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}}, + {"command": "mesh.hex.generate", "params": {"body": "Bracket", "size": 3, "method": "extrude"}}, + {"command": "model.set.create", "params": {"name": "bracket", "geometry": [{"body": "Bracket"}]}}, + {"command": "model.set.create", "params": {"name": "top", "geometry": [{"body": "Bracket", "face": "F2:side:l5"}]}}, + aluminium("Aluminium"), + {"command": "model.section.create", "params": {"name": "Bracket", "set": "bracket", "material": "Aluminium", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}}}, + {"command": "model.load.create", "params": {"name": "Gravity", "type": "gravity", "acceleration": [0, 0, -GRAVITY]}}, + {"command": "model.step.create", "params": {"name": "Drop", "procedure": step, "loads": ["Gravity"]}}, + ]) +} + /// 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 { @@ -295,4 +355,45 @@ mod tests { assert_eq!((r["completed"].as_bool(), r["frames"].as_u64()), (Some(true), Some(11)), "{r}"); assert!(r["energy_balance"].as_f64().unwrap() < 0.05, "{r}"); } + + /// `file.sample NAME`, checked clean: ready, no errors or warnings. + fn open(name: &str) -> Session { + let mut s = Session::default(); + s.execute("file.sample", &json!({"name": name})).unwrap(); + let check = s.execute("model.check", &json!({})).unwrap(); + assert_eq!((check["ready"].as_bool(), check["errors"].as_u64(), check["warnings"].as_u64()), (Some(true), Some(0), Some(0)), "{check}"); + s + } + + #[test] + fn the_drop_test_opens_ready_to_solve() { + let s = open("drop-test"); + assert!(s.model.elements().len() > 1000); + } + + /// The bracket's mass is ρ V (V: foot, upright and the fillet's corner, 36 − 9π mm² by 40 mm), + /// it meets the floor at √(2 g h) with ½ m v² of kinetic energy and conserves energy; by the + /// end the floor has taken half its downward momentum and turned kinetic energy into + /// vibration. + #[test] + #[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"] + fn the_drop_test_lands_at_the_free_fall_speed_and_bounces() { + let mut s = open("drop-test"); + let r = s.execute("solve.run", &json!({})).unwrap(); + let r = &r["steps"][0]["summary"]; + let volume = 60.0 * 8.0 * 40.0 + 8.0 * 42.0 * 40.0 + (36.0 - 9.0 * std::f64::consts::PI) * 40.0; + let mass = 2.7e-9 * volume; + let close = |a: f64, b: f64, rel: f64| (a - b).abs() <= rel * b.abs(); + assert!(close(r["total_mass"].as_f64().unwrap(), mass, 0.005), "{r}"); + assert!(r["completed"].as_bool() == Some(true) && r["energy_balance"].as_f64().unwrap() < 0.01, "{r}"); + let h = s.execute("solve.history", &json!({"step": "Drop"})).unwrap(); + let speed = (2.0 * GRAVITY * DROP_HEIGHT).sqrt(); + let pz = |i: usize| h["momentum"][i][2].as_f64().unwrap(); + let kinetic = |i: usize| h["energies"][i]["kinetic"].as_f64().unwrap(); + assert!(close(pz(0), -mass * speed, 0.005), "{}", pz(0)); + assert!(close(kinetic(0), 0.5 * mass * speed * speed, 0.01), "{}", kinetic(0)); + let last = h["time"].as_array().unwrap().len() - 1; + let kept = pz(last) / pz(0); + assert!(kept > 0.4 && kept < 0.6 && kinetic(last) < kinetic(0), "{} {}", pz(last), kinetic(last)); + } } diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..13cd260 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,28 @@ +# Examples + +Every example is a built-in sample: open it from **File ▸ Samples** (or the start screen), with +`fieldcraft --sample NAME`, or with the `file.sample` command (`{"name": "drop-test"}`), then +press **Solve** (`solve.run`). Each opens fully defined — geometry, mesh, materials, boundary +conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our +own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus) +or one written in `crates/engine/src/sample.rs`). Each sample has a test in +`crates/engine/src/sample.rs` that opens it, checks it clean, solves it and holds its result to +the check given below. + +## Drop test (`drop-test`) + +![The bracket at 0.1 ms after striking the tilted floor, von Mises stress up to 123 MPa in the bend](media/examples/drop-test.png) + +An aluminium L-bracket (60 × 40 mm foot, 50 mm upright, 8 mm thick, R6 fillet) falls 2 m onto a +rigid floor tilted 15°, so the end of its foot strikes first. Explicit dynamics for 1 ms with +gravity: 1 911 swept hex8 elements (one-point, stiffness hourglass control), bilinear plasticity +(yield 240 MPa), the floor a rigid wall. About 8 400 increments, a few seconds. + +**Look at:** the frames from the strike (Results ▸ Frame): the compression wave leaves the +struck corner and the upright's inertia bends the fillet, where von Mises peaks near 120 MPa — +below yield, so the drop leaves no permanent set. The energies plot shows the kinetic energy +turning into vibration with a balance under 0.01 %. + +**Check:** the mass equals ρ V of the bracket (0.5 %); it meets the floor at √(2 g h) = +6.26 m/s, with ½ m v² = 1 748 mJ of kinetic energy (1 %); energy is conserved (balance < 1 %); +by 1 ms the floor has taken about half its downward momentum. diff --git a/docs/media/examples/drop-test.png b/docs/media/examples/drop-test.png new file mode 100644 index 0000000..ed8c833 Binary files /dev/null and b/docs/media/examples/drop-test.png differ