Files
OneCAD-Simulation/crates/engine/src/sample.rs
T
jyvikoandClaude Opus 5.5 90948833e0 Samples: the crank arm meshed structured from a partitioned half
crank-arm (and crank-mirror, kept as the same model under the name of
its mirror workflow) now cut the crank on its symmetry plane and the
half into seven cells: across the arm at x = 30, 60, 110 and 148, and on
two diagonals radial to the pocket's corner fillets. Every cell's
cross-section maps (four-sided blocks, the half O-grids round the bores,
triangle primitives at the pocket's corners); exact edge seeds give each
cut the same divisions on both sides, so mesh.cells merges all six cuts
and mesh.mirror reflects the half: 7 710 hex20, scaled Jacobian min 0.37
(was 0.17, and 0.009 for the swept mirrored half), edge ratio at most
7.0. Root σxx ±62.43 MPa against M/Z 62.6 MPa, the spindle carries the
1500 N. The test asserts the quality floor. Gallery images regenerated.

Fixes #247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:17:20 -04:00

982 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sample models as scripts of ordinary commands, so they build the same way a user's script
//! does.
//!
//! [`script`], the model the desktop app opens with `--sample`: two parts side by side, a
//! 40 × 20 × 10 block of 5 mm hex8 cells, and a 15 mm cube split into 162 tet4 elements (each
//! 5 mm cell into six tetrahedra around its main diagonal, the Kuhn subdivision, which is
//! conforming between neighbouring cells). Every element is positively oriented.
//!
//! [`cantilever`], a model ready to solve: a steel beam clamped at one end with a load on the
//! other, a static and a frequency step. [`impact`]: the same beam flying onto a rigid wall, an
//! explicit dynamic step.
//!
//! The examples gallery (docs/examples.md), each ready to solve with `solve.run`:
//! [`drop_test`], [`rubber_seal`], [`flow_past`], [`heated_bracket`], [`bracket_hex`] and [`crank_arm`]. Their
//! geometry is a SolidCraft script run when the sample opens ([`Sample::design`]): a corpus
//! recipe (`tests/corpus`) or [`UPRIGHT_BRACKET`].
use fieldcraft_geometry::Shape;
use serde_json::{Value, json};
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
pub struct Sample {
pub name: &'static str,
pub label: &'static str,
/// One line: what it shows.
pub about: &'static str,
/// The SolidCraft script (`{"commands": [...]}`, the corpus recipe form) that builds the
/// sample's geometry, opened as its design before [`Sample::script`] runs.
pub design: Option<&'static str>,
/// Commands run on the opened design before [`Sample::script`] (datums and partitions), so
/// the script is given the bodies as they cut it.
pub prepare: Option<fn() -> Value>,
/// The model's commands, given the design's bodies (none without a design).
pub script: fn(&[Shape]) -> Value,
}
/// Every sample, the default first.
pub const SAMPLES: &[Sample] = &[
Sample {
name: "parts",
label: "Hex and Tet Parts",
about: "A hex8 block beside a tet4 cube: meshes to look at, nothing to solve",
design: None,
prepare: None,
script: |_| script(),
},
Sample {
name: "cantilever",
label: "Cantilever",
about: "A clamped steel beam with an end load: a static and a frequency step, ready to solve",
design: None,
prepare: None,
script: |_| cantilever(),
},
Sample {
name: "impact",
label: "Bar Impact",
about: "The steel beam flying onto a rigid wall: an explicit dynamic step, ready to solve",
design: None,
prepare: 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),
prepare: None,
script: |_| drop_test(),
},
Sample {
name: "rubber-seal",
label: "Rubber Seal",
about: "A rubber gasket ring squeezed 25 % between bonded plates: nonlinear static, Mooney–Rivlin, large strain",
design: None,
prepare: None,
script: |_| rubber_seal(),
},
Sample {
name: "flow-past",
label: "Flow Past a Bracket",
about: "Air flowing past an L-bracket on the floor: lattice Boltzmann flow and the force on each face",
design: Some(UPRIGHT_BRACKET),
prepare: None,
script: |_| flow_past(),
},
Sample {
name: "heated-bracket",
label: "Heated Bracket",
about: "A steel bracket clamped at its holes and heated at one end: steady heat conduction and the thermal stress",
design: Some(FILLETED_BRACKET),
prepare: None,
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),
prepare: None,
script: bracket_hex,
},
Sample {
name: "crank-mirror",
label: "Mirrored Crank Arm",
about: "The crank arm of crank-arm under the name of its mirror workflow: a partitioned half meshed structured all-hex (hex20) and mirrored into one conforming mesh",
design: Some(CRANK_ARM),
prepare: Some(crank_prepare),
script: crank_arm,
},
Sample {
name: "crank-arm",
label: "Bicycle Crank Arm",
about: "An aluminium bicycle crank meshed structured all-hex (hex20) from a partitioned half and its mirror image: square bore fixed, a rider's weight on the pedal, von Mises",
design: Some(CRANK_ARM),
prepare: Some(crank_prepare),
script: crank_arm,
},
];
/// The sample called `name`.
pub fn find(name: &str) -> Option<&'static Sample> {
SAMPLES.iter().find(|s| s.name == name)
}
/// Hex block: cells along x, y, z and the cell size (mm).
const HEX_CELLS: [u32; 3] = [8, 4, 2];
/// Tet cube: cells per side and its offset from the origin (mm).
const TET_CELLS: u32 = 3;
const TET_ORIGIN: [f64; 3] = [50.0, 2.5, 0.0];
const CELL: f64 = 5.0;
/// The script: `mesh.node.add` and `mesh.element.add` steps.
pub fn script() -> Value {
let mut steps = Vec::new();
let mut node = 0u32;
let mut element = 0u32;
// Hex block.
let [nx, ny, nz] = HEX_CELLS;
let first = node + 1;
let hid = |i: u32, j: u32, k: u32| first + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
for k in 0..=nz {
for j in 0..=ny {
for i in 0..=nx {
node += 1;
steps.push(json!({"command": "mesh.node.add", "params": {"label": node, "xyz": [f64::from(i) * CELL, f64::from(j) * CELL, f64::from(k) * CELL]}}));
}
}
}
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
element += 1;
let nodes = [
hid(i, j, k),
hid(i + 1, j, k),
hid(i + 1, j + 1, k),
hid(i, j + 1, k),
hid(i, j, k + 1),
hid(i + 1, j, k + 1),
hid(i + 1, j + 1, k + 1),
hid(i, j + 1, k + 1),
];
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "hex8", "nodes": nodes}}));
}
}
}
// Tet cube.
let n = TET_CELLS;
let first = node + 1;
let tid = |i: u32, j: u32, k: u32| first + i + j * (n + 1) + k * (n + 1) * (n + 1);
let at = |i: u32, j: u32, k: u32| [TET_ORIGIN[0] + f64::from(i) * CELL, TET_ORIGIN[1] + f64::from(j) * CELL, TET_ORIGIN[2] + f64::from(k) * CELL];
for k in 0..=n {
for j in 0..=n {
for i in 0..=n {
node += 1;
steps.push(json!({"command": "mesh.node.add", "params": {"label": node, "xyz": at(i, j, k)}}));
}
}
}
// The six axis orders: each walks from the cell's corner (0,0,0) to (1,1,1) one axis at a time.
const ORDERS: [[usize; 3]; 6] = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
for k in 0..n {
for j in 0..n {
for i in 0..n {
for order in ORDERS {
let mut c = [0u32; 3];
let mut corners = vec![c];
for axis in order {
if let Some(x) = c.get_mut(axis) {
*x = 1;
}
corners.push(c);
}
let mut nodes: Vec<u32> = corners.iter().map(|c| tid(i + c[0], j + c[1], k + c[2])).collect();
let p: Vec<[f64; 3]> = corners.iter().map(|c| at(i + c[0], j + c[1], k + c[2])).collect();
if signed_volume(&p) < 0.0 {
nodes.swap(1, 2);
}
element += 1;
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "tet4", "nodes": nodes}}));
}
}
}
}
Value::Array(steps)
}
/// The cantilever's length (x), width (y) and height (z) in mm, and its hex8 cells along each.
pub const CANTILEVER_SIZE: [f64; 3] = [100.0, 10.0, 20.0];
const CANTILEVER_CELLS: [u32; 3] = [20, 2, 4];
/// The beam's mesh in steel (E = 210 GPa, ν = 0.3, ρ = 7.85 t/m³ in mm, t, s, N, MPa): nodes,
/// hex8 elements, sets `beam` (elements), `root` (nodes at x = 0) and `tip` (at x = L), the
/// material `Steel` and a solid section `Beam` with the given formulation.
fn beam(formulation: Value) -> Vec<Value> {
let [nx, ny, nz] = CANTILEVER_CELLS;
let [lx, ly, lz] = CANTILEVER_SIZE;
let id = |i: u32, j: u32, k: u32| 1 + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
let mut steps = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
for k in 0..=nz {
for j in 0..=ny {
for i in 0..=nx {
let xyz = [lx * f64::from(i) / f64::from(nx), ly * f64::from(j) / f64::from(ny), lz * f64::from(k) / f64::from(nz)];
steps.push(json!({"command": "mesh.node.add", "params": {"label": id(i, j, k), "xyz": xyz}}));
}
}
}
let mut element = 0u32;
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
element += 1;
let nodes = [
id(i, j, k),
id(i + 1, j, k),
id(i + 1, j + 1, k),
id(i, j + 1, k),
id(i, j, k + 1),
id(i + 1, j, k + 1),
id(i + 1, j + 1, k + 1),
id(i, j + 1, k + 1),
];
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "hex8", "nodes": nodes}}));
}
}
}
let end = |i: u32| -> Vec<u32> { (0..=nz).flat_map(|k| (0..=ny).map(move |j| id(i, j, k))).collect() };
steps.extend([
json!({"command": "model.set.create", "params": {"name": "beam", "elements": (1..=element).collect::<Vec<_>>()}}),
json!({"command": "model.set.create", "params": {"name": "root", "nodes": end(0)}}),
json!({"command": "model.set.create", "params": {"name": "tip", "nodes": end(nx)}}),
json!({"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}}),
json!({"command": "model.section.create", "params": {"name": "Beam", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": formulation}}),
]);
steps
}
/// A steel cantilever of hex8 elements with incompatible modes ([`CANTILEVER_SIZE`]), clamped at
/// x = 0 (set `root`), with a total force of 1000 N down on the free end (set `tip`); step
/// `Load` (static) and step `Modes` (six modes). Euler–Bernoulli puts the tip
/// 4 P L³ / (E w h³) = 0.238 mm down.
pub fn cantilever() -> Value {
let mut steps = beam(json!({"incompatible_modes": true}));
steps.extend([
json!({"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}}),
json!({"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}}),
json!({"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}}),
json!({"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 6}, "boundary_conditions": ["Clamp"]}}),
]);
Value::Array(steps)
}
/// The same steel beam in one-point hex8 elements, flying at 10 m/s onto a rigid wall at x = 0:
/// step `Impact` (explicit dynamics, 40 µs, ten field frames, energies and the tip's history).
/// The compression wave runs the 100 mm bar in about 19 µs (c = √(E/ρ) ≈ 5.2 mm/µs).
pub fn impact() -> Value {
let mut steps = beam(json!({"integration": "reduced"}));
let step = json!({
"type": "explicit_dynamic", "end_time": 4e-5, "output_interval": 4e-6, "history_interval": 2e-7, "history_nodes": "tip",
"initial_velocities": [{"set": "all_nodes", "velocity": [-10000, 0, 0]}],
"rigid_walls": [{"set": "root", "point": [0, 0, 0], "normal": [1, 0, 0]}]
});
let [nx, ny, nz] = CANTILEVER_CELLS;
let nodes = (nx + 1) * (ny + 1) * (nz + 1);
steps.extend([
json!({"command": "model.set.create", "params": {"name": "all_nodes", "nodes": (1..=nodes).collect::<Vec<_>>()}}),
json!({"command": "model.step.create", "params": {"name": "Impact", "procedure": step}}),
]);
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"]}},
])
}
/// The rubber seal: inner and outer radius and height of the ring (mm), and how far it is squeezed.
pub const SEAL: [f64; 3] = [20.0, 30.0, 10.0];
pub const SEAL_SQUEEZE: f64 = 2.5;
/// A rubber gasket ring ([`SEAL`]: 20 mm inside radius, 30 mm outside, 10 mm high) of hybrid hex8
/// elements, four mapped quarter blocks with arc edges (body `Seal`, block faces `bottom` and
/// `top`), squeezed by [`SEAL_SQUEEZE`] (25 %) between plates bonded to it: `bottom` fixed, `top`
/// moved down with its in-plane motion held. Generic rubber: E = 6 MPa, ν = 0.49, Mooney–Rivlin
/// with C01 = 0.2 MPa (C10 = μ/2 − C01 = 0.81 MPa). Step `Squeeze` (nonlinear static).
pub fn rubber_seal() -> Value {
let [inner, outer, height] = SEAL;
let mut steps = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
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 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],
"edges": [arc(1, 4, 0.0), arc(2, 3, 0.0), arc(5, 8, height), arc(6, 7, height)],
"faces": {"1": "bottom", "2": "top"}
}}));
}
steps.extend([
json!({"command": "model.set.create", "params": {"name": "seal", "geometry": [{"body": "Seal"}]}}),
json!({"command": "model.set.create", "params": {"name": "bottom", "geometry": [{"body": "Seal", "face": "bottom"}]}}),
json!({"command": "model.set.create", "params": {"name": "top", "geometry": [{"body": "Seal", "face": "top"}]}}),
json!({"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": 6, "poisson": 0.49}, "density": 1.1e-9,
"hyperelastic": {"type": "mooney_rivlin", "c01": 0.2}}}),
json!({"command": "model.section.create", "params": {"name": "Seal", "set": "seal", "material": "Rubber", "kind": {"type": "solid"}, "formulation": {"hybrid": true}}}),
json!({"command": "model.boundary_condition.create", "params": {"name": "Base", "type": "fixed", "target": {"set": "bottom"}}}),
json!({"command": "model.boundary_condition.create", "params": {"name": "Press", "type": "displacement", "target": {"set": "top"}, "values": [0, 0, -SEAL_SQUEEZE, null, null, null]}}),
json!({"command": "model.step.create", "params": {"name": "Squeeze", "procedure": {"type": "nonlinear_static", "initial_increment": 0.1}, "boundary_conditions": ["Base", "Press"]}}),
]);
Value::Array(steps)
}
/// The flow sample's free-stream speed, mm/s.
pub const FLOW_SPEED: f64 = 25.0;
/// Air at 20 °C: density (t/mm³) and dynamic viscosity (MPa s).
const AIR: [f64; 2] = [1.2e-12, 1.8e-11];
/// Air at [`FLOW_SPEED`] past [`UPRIGHT_BRACKET`] standing on the floor, the upright's back face
/// to the wind: a 240 × 120 × 100 mm box of 2.5 mm cells (184 320), a uniform inlet at x_min, an
/// outlet at zero pressure at x_max, a no-slip floor and slip walls at the sides and top. Reynolds
/// number ρ U h / μ = 83 on the upright's 50 mm height: laminar, a steady wake. Step `Flow` (16 s,
/// about 6.7 flow-throughs; ten frames) reports the force on each named face of the bracket.
pub fn flow_past() -> Value {
let [density, viscosity] = AIR;
json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.material.create", "params": {"name": "Air", "density": density, "fluid": {"type": "newtonian", "viscosity": viscosity}}},
{"command": "model.fluid_domain.create", "params": {
"name": "Wind", "material": "Air", "min": [-60, -40, 0], "max": [180, 80, 100], "spacing": 2.5,
"faces": {
"x_min": {"type": "velocity", "velocity": [FLOW_SPEED, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
"y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}
},
"obstacles": ["Bracket"], "initial_velocity": [FLOW_SPEED, 0, 0]
}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Wind", "end_time": 16, "frames": 10}}},
])
}
/// The corpus filleted bracket (`tests/corpus/filleted-bracket`): an 8 mm L-bracket, foot along
/// x (60 mm) and upright along y (50 mm), extruded 40 mm along z, an R6 fillet, an 8 mm hole
/// through each leg (`F4` through the foot at x = 40, `F5` through the upright at y = 32).
pub const FILLETED_BRACKET: &str = include_str!("../../../tests/corpus/filleted-bracket/recipe.json");
/// The heated bracket: the heater's and the surrounding air's temperatures (°C, also the stress
/// free temperature), and steel's expansion coefficient (1/K).
pub const HEATER: f64 = 120.0;
pub const AMBIENT: f64 = 20.0;
pub const STEEL_EXPANSION: f64 = 1.2e-5;
/// [`FILLETED_BRACKET`] in steel (k = 50 W/(m K), α = 12 × 10⁻⁶/K, stress free at 20 °C), tet10
/// elements of 4 mm: its upright's end (`F2:side:l5`, set `hot`) held at [`HEATER`], the rest of
/// its outside (set `skin`) cooled by still air at [`AMBIENT`] (h = 10 W/(m² K)), both hole bores
/// clamped (set `holes`). Step `Heat` (coupled temperature–displacement, steady): the
/// temperature field and the stress of the expansion the clamps hold back. In mm, t, s, N, MPa:
/// 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 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"}},
{"command": "mesh.tet.generate", "params": {"body": "Bracket", "size": 4, "order": "quadratic", "tolerance": 0.05}},
{"command": "model.set.create", "params": {"name": "bracket", "geometry": [{"body": "Bracket"}]}},
{"command": "model.set.create", "params": {"name": "holes", "geometry": holes}},
{"command": "model.set.create", "params": {"name": "hot", "geometry": [face("F2:side:l5")]}},
{"command": "model.set.create", "params": {"name": "skin", "geometry": skin}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9,
"thermal": {"conductivity": 50, "specific_heat": 4.6e8, "expansion": STEEL_EXPANSION, "reference_temperature": AMBIENT}}},
{"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": "holes"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "hot"}, "value": HEATER}},
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.01, "sink": AMBIENT}},
{"command": "model.step.create", "params": {"name": "Heat", "procedure": {"type": "coupled_temperature_displacement"}, "boundary_conditions": ["Clamp", "Heater"], "loads": ["Air"]}},
])
}
/// 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"]}},
])
}
/// The bicycle crank arm (`crates/engine/samples/crank-arm.json`): a 170 mm aluminium arm, 16 mm
/// thick and tapering from 38 to 24 mm, joined by tangent root arcs to a Ø46 × 22 spindle boss
/// with a 12.7 mm square bore (R1.5 corners) and a Ø30 × 20 pedal boss with a Ø14 hole, and an
/// 82 × 14 × 8 pocket in its back (R5 corners). Made to be swept all-hex by `mesh.hex.generate`
/// as a stack of extrusions along z: no pocket floor fillet or boss chamfers, and the root arcs
/// meet the bosses about 20° off tangent (tangent to a circle 0.5 mm inside each boss), since a
/// tangent meeting leaves a zero-angle cusp on the arm's top face.
pub const CRANK_ARM: &str = include_str!("../samples/crank-arm.json");
/// The crank arm's pedal load (N): a standing rider's weight on one pedal.
pub const PEDAL_LOAD: f64 = 1500.0;
/// The cells of the half `CrankArm/a` (y < 0) after [`crank_prepare`], from the spindle to the
/// pedal: the spindle boss and the arm's root (`X30`), the pocket's spindle end below the
/// diagonal `D1`, the arm between `D1` and `X60`, the middle of the arm (`X60` to `X110`), the arm
/// between `X110` and `D2`, the pocket's pedal end beyond `D2`, and the pedal boss (`X148`).
pub const CRANK_CELLS: [&str; 7] = [
"CrankArm/a/a",
"CrankArm/a/b/a",
"CrankArm/a/b/b/a",
"CrankArm/a/b/b/b/a",
"CrankArm/a/b/b/b/b/a",
"CrankArm/a/b/b/b/b/b/a",
"CrankArm/a/b/b/b/b/b/b",
];
/// The diagonals' angles from the arm's axis (degrees): `D1` through the centre of the pocket's
/// spindle-end corner fillet (53, −2), `D2` through the pedal-end one (125, −2), each radial to
/// its fillet so it meets the pocket wall square. Steeper than 45° from the pocket's floor edge
/// (y = 0) on its inner side and shallower than 45° from the arm's edge on its outer side, so the
/// pocket's middle keeps its corners there and the cells beyond it do not gain one.
const CRANK_DIAGONALS: [f64; 2] = [40.0, 32.0];
/// [`CRANK_ARM`] meshed structured all-hex (hex20 of 3 mm) from a partitioned half and mirrored,
/// in 6061-T6 aluminium: the square bore's faces and corner fillets (set `spindle`) fixed,
/// [`PEDAL_LOAD`] on the pedal hole (set `pedal`), in the crank's plane and across the arm (−y).
/// Step `Pedal` (linear static). The faces are picked by their surfaces, not their names: the
/// square bore's are the vertical planes 6.35 mm from the spindle axis and the R1.5 cylinders
/// beside it, the pedal hole is the Ø14 cylinder on the pedal axis.
///
/// The mesh: [`crank_prepare`] cuts the crank on its symmetry plane and the half `CrankArm/a`
/// into the seven [`CRANK_CELLS`], each a stack of extrusions whose cross-section splits into
/// regions that map: four-sided regions (transfinite), the half ring round each bore, and the
/// pocket's four corners cut off by the partitions, three-sided (the triangle primitive). Exact
/// edge seeds give each cut the same divisions in the two cells it divides, so `mesh.cells`
/// sweeps every cell (`extrude`) and merges them node for node; `mesh.mirror` reflects the half
/// onto `CrankArm/b`, joined on the plane.
pub fn crank_arm(bodies: &[Shape]) -> Value {
let cells = CRANK_CELLS;
let mut mesh = crank_seeds();
mesh.push(json!({"command": "mesh.cells", "params": {"bodies": cells, "default": "extrude", "size": 3, "order": 2}}));
// Each cell's image is part of the uncut half.
let images: serde_json::Map<String, Value> = cells.iter().map(|c| (c.to_string(), json!("CrankArm/b"))).collect();
mesh.push(json!({"command": "mesh.mirror", "params": {"plane": "Sym", "bodies": cells, "as": images}}));
crank_model(bodies, mesh)
}
/// The edge seeds that make every cut's divisions alike on its two sides: each cut's line in
/// the arm's cross-section (its bottom edge, z = 0) and, for the diagonals and the cuts across the
/// pocket, its line on the pocket's floor (z = 8); and the 16 mm walls of the end cells on their
/// cuts, in six layers as the pocket's floor level splits the cells between them (3 + 3). The
/// counts are those the cells' mapped regions need: the cut `X30` faces the spindle boss's arc
/// across the root (7), the arm between the diagonals is one block of five rows.
fn crank_seeds() -> Vec<Value> {
let [c1, c2, c3, c4, c5, c6, c7] = CRANK_CELLS;
let cut = |name: &str, a: &str, b: &str, face: &str, count: u32| {
json!({"command": "mesh.seed.edge", "params": {"edges": [
{"body": a, "edge": format!("{face}|partition:{name}:a")},
{"body": b, "edge": format!("{face}|partition:{name}:b")},
], "count": count}})
};
let mut seeds = vec![cut("X30", c1, c2, "F2:start", 7), cut("X148", c6, c7, "F2:start", 5)];
for (name, a, b) in [("D1", c2, c3), ("X60", c3, c4), ("X110", c4, c5), ("D2", c5, c6)] {
seeds.push(cut(name, a, b, "F2:start", 5));
seeds.push(cut(name, a, b, "F6:top", 3));
}
seeds.push(json!({"command": "mesh.seed.edge", "params": {"edges": [
{"body": c1, "edge": "F2:side:lo|partition:X30:a"},
{"body": c7, "edge": "F2:side:lo|partition:X148:b"},
], "count": 6}}));
seeds
}
/// The crank arm's symmetry plane (`Sym`, y = 0) and the partition by it (`Half`), then the
/// half `CrankArm/a` cut into [`CRANK_CELLS`]: across the arm at x = 30 (the root, beyond the
/// spindle boss), 60 and 110 (the pocket's straight middle) and 148 (before the pedal boss), and
/// on the diagonals of [`CRANK_DIAGONALS`]. Each cut names the one cell it divides.
fn crank_prepare() -> Value {
let [d1, d2] = CRANK_DIAGONALS.map(f64::to_radians);
let across = |x: f64| json!({"type": "offset", "plane": "yz", "offset": x});
let cuts = [
("X30", across(30.0), "CrankArm/a"),
("D1", json!({"type": "point_normal", "point": [53, -2, 0], "normal": [d1.sin(), -d1.cos(), 0]}), "CrankArm/a/b"),
("X60", across(60.0), "CrankArm/a/b/b"),
("X110", across(110.0), "CrankArm/a/b/b/b"),
("D2", json!({"type": "point_normal", "point": [125, -2, 0], "normal": [d2.sin(), d2.cos(), 0]}), "CrankArm/a/b/b/b/b"),
("X148", across(148.0), "CrankArm/a/b/b/b/b/b"),
];
let mut script = vec![
json!({"command": "geometry.datum", "params": {"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}}}),
json!({"command": "geometry.partition", "params": {"name": "Half", "datum": "Sym"}}),
];
for (name, by, body) in cuts {
script.push(json!({"command": "geometry.datum", "params": {"name": name, "by": by}}));
script.push(json!({"command": "geometry.partition", "params": {"name": name, "datum": name, "bodies": [body]}}));
}
Value::Array(script)
}
/// The crank's model after the `mesh` commands: material, the spindle fixed, the rider on the
/// pedal, step `Pedal`.
fn crank_model(bodies: &[Shape], mesh: Vec<Value>) -> Value {
let [spindle, pedal] = crank_faces(bodies);
let mut script = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
script.extend(mesh);
script.extend(json_array(json!([
{"command": "model.set.create", "params": {"name": "crank", "geometry": bodies.iter().map(|b| json!({"body": b.name})).collect::<Vec<_>>()}},
{"command": "model.set.create", "params": {"name": "spindle", "geometry": spindle}},
{"command": "model.set.create", "params": {"name": "pedal", "geometry": pedal}},
{"command": "model.material.create", "params": {"name": "Al6061-T6", "elastic": {"type": "isotropic", "young": 68900, "poisson": 0.33}, "density": 2.7e-9}},
{"command": "model.section.create", "params": {"name": "Crank", "set": "crank", "material": "Al6061-T6", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Spindle", "type": "fixed", "target": {"set": "spindle"}}},
{"command": "model.load.create", "params": {"name": "Rider", "type": "force", "target": {"set": "pedal"}, "vector": [0, -PEDAL_LOAD, 0]}},
{"command": "model.step.create", "params": {"name": "Pedal", "procedure": {"type": "static"}, "boundary_conditions": ["Spindle"], "loads": ["Rider"]}},
])));
Value::Array(script)
}
fn json_array(v: Value) -> Vec<Value> {
match v {
Value::Array(a) => a,
other => vec![other],
}
}
/// The crank's square-bore faces and pedal-hole faces as set members, found by their surfaces.
fn crank_faces(bodies: &[Shape]) -> [Vec<Value>; 2] {
use fieldcraft_geometry::FaceSurface;
let vertical = |axis: [f64; 3]| axis[2].abs() > 1.0 - 1e-6;
let (mut spindle, mut pedal) = (Vec::new(), Vec::new());
for body in bodies {
let surfaces = body.face_surfaces().unwrap_or_default();
for (face, surface) in body.faces().iter().zip(surfaces) {
let member = json!({"body": body.name, "face": face.name});
match surface {
Some(FaceSurface::Plane { normal, point })
if normal[2].abs() < 1e-6 && ((normal[0] * point[0] + normal[1] * point[1]).abs() - 6.35).abs() < 1e-6 =>
{
spindle.push(member)
}
Some(FaceSurface::Cylinder { origin, axis, radius })
if vertical(axis) && (radius - 1.5).abs() < 1e-6 && origin[0].hypot(origin[1]) < 8.0 =>
{
spindle.push(member)
}
Some(FaceSurface::Cylinder { origin, axis, radius })
if vertical(axis) && (radius - 7.0).abs() < 1e-6 && (origin[0] - 170.0).hypot(origin[1]) < 1e-6 =>
{
pedal.push(member)
}
_ => {}
}
}
}
[spindle, pedal]
}
/// 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 {
let (Some(a), Some(b), Some(c), Some(d)) = (p.first(), p.get(1), p.get(2), p.get(3)) else { return 0.0 };
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let w = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
u[0] * (v[1] * w[2] - v[2] * w[1]) - u[1] * (v[0] * w[2] - v[2] * w[0]) + u[2] * (v[0] * w[1] - v[1] * w[0])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
/// The `file.sample` form offers every sample, by name.
#[test]
fn the_form_lists_every_sample() {
let Some(crate::schema::Kind::Choice(options)) = crate::cmd::forms::SAMPLE.first().map(|f| f.kind) else { panic!("not a choice") };
let mut offered: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
let mut names: Vec<&str> = SAMPLES.iter().map(|s| s.name).collect();
offered.sort_unstable();
names.sort_unstable();
assert_eq!(offered, names);
}
#[test]
fn the_sample_builds_positive_elements_that_fill_both_parts() {
let mut s = Session::default();
s.run_script(&script()).unwrap();
let info = s.execute("model.info", &json!({})).unwrap();
assert_eq!(info["elements"]["hex8"], 64);
assert_eq!(info["elements"]["tet4"], 162);
assert_eq!(info["nodes"], 9 * 5 * 3 + 4 * 4 * 4);
// Tet volumes are positive and add up to the cube's.
let mut volume = 0.0;
for e in s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Tet4) {
let p: Vec<[f64; 3]> = e.nodes.iter().map(|n| s.model.node(*n).unwrap().xyz).collect();
let v = signed_volume(&p) / 6.0;
assert!(v > 0.0);
volume += v;
}
assert!((volume - 15.0f64.powi(3)).abs() < 1e-9, "{volume}");
}
#[test]
fn the_cantilever_solves_close_to_beam_theory() {
let mut s = Session::default();
s.run_script(&cantilever()).unwrap();
let r = s.execute("solve.static", &json!({})).unwrap();
let [l, w, h] = CANTILEVER_SIZE;
let beam = 4.0 * 1000.0 * l.powi(3) / (210000.0 * w * h.powi(3));
let tip = r["max_displacement"]["value"].as_f64().unwrap();
// Shear deformation adds a little (L/h = 5).
assert!(tip > beam && tip < 1.1 * beam, "{tip} vs {beam}");
let f = s.execute("solve.frequency", &json!({})).unwrap();
assert_eq!(f["modes"].as_array().map(Vec::len), Some(6));
}
#[test]
fn the_impact_runs_and_keeps_frames_with_stresses() {
let mut s = Session::default();
s.run_script(&impact()).unwrap();
let r = s.execute("solve.explicit", &json!({})).unwrap();
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
}
/// The squeeze force of the ring, against the closed form for an incompressible Mooney–Rivlin
/// solid in uniaxial compression, P = 2 (λ − λ⁻²)(C10 + C01/λ) per unit of original area: the
/// same ring between frictionless plates (each face held only along z, the rigid motions
/// held at three nodes on the symmetry planes) compresses evenly and matches it within 3 %
/// (ν = 0.49 is nearly incompressible); bonded, the bulge needs more force (shape factor
/// ¼ (b − a)/h = 0.5 by the loaded over the free area), within 2.5 times.
#[test]
fn the_rubber_seal_squeezes_as_mooney_rivlin_rubber() {
let [inner, outer, height] = SEAL;
let area = std::f64::consts::PI * (outer * outer - inner * inner);
let lambda: f64 = 1.0 - SEAL_SQUEEZE / height;
let (c01, c10) = (0.2, 6.0 / (2.0 * 1.49) / 2.0 - 0.2);
let closed = 2.0 * (lambda - lambda.powi(-2)) * (c10 + c01 / lambda) * area;
let squeeze = |s: &mut Session| -> f64 {
s.execute("solve.nonlinear", &json!({})).unwrap();
let r = s.execute("results.reactions", &json!({"step": "Squeeze", "sets": ["top"]})).unwrap();
r["sets"][0]["force"][2].as_f64().unwrap()
};
let mut s = open("rubber-seal");
let bonded = squeeze(&mut s);
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]),
];
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.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");
let stiffer = bonded / free;
assert!(stiffer > 1.05 && stiffer < 2.5, "bonded {bonded} N, frictionless {free} N");
}
#[test]
fn the_flow_sample_opens_ready_to_solve() {
let s = open("flow-past");
assert_eq!(s.model.fluid_domains().len(), 1);
}
/// The drag on the bracket, as a coefficient on the upright's frontal area (40 × 50 mm), is
/// that of a bluff plate standing on a wall at a Reynolds number of 83, raised by the 17 %
/// blockage of the box: between 1 and 3.5 (a flat plate broadside to the flow is about 1.2 at
/// high Reynolds number and rises as it falls). The lattice conserves mass.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_flow_past_the_bracket_has_a_bluff_body_drag() {
let mut s = open("flow-past");
let r = s.execute("solve.run", &json!({})).unwrap();
let r = &r["steps"][0]["summary"];
let drag: f64 = r["forces"].as_array().unwrap().iter().filter(|f| f["body"] == "Bracket").map(|f| f["mean"][0].as_f64().unwrap()).sum();
let [density, _] = AIR;
let cd = drag / (0.5 * density * FLOW_SPEED * FLOW_SPEED * 40.0 * 50.0);
assert!(cd > 1.0 && cd < 3.5, "Cd {cd}: {r}");
assert!(r["mass"]["relative_change"].as_f64().unwrap().abs() < 0.01, "{r}");
}
/// The heated bracket's temperatures lie between the air's and the heater's, falling along
/// the bracket away from the heater. Its thermal expansion: with the air at the heater's
/// temperature the bracket warms evenly by ΔT, and held at the foot's hole alone (at x = 40)
/// it grows freely, so a point moves α ΔT times its distance from the hole's axis along x:
/// the upright's top by −0.0432 mm (within 3 %), the foot's end by +0.024 mm (within 6 %).
/// The clamped bore cannot grow, which holds both points back a little; how much depends on
/// the tet mesh, which differs between platforms (#115): 2.1 % at the top on the Linux CI.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_heated_bracket_warms_and_expands_as_steel() {
let mut s = open("heated-bracket");
let r = s.execute("solve.run", &json!({})).unwrap();
let r = &r["steps"][0]["summary"];
assert!((r["max_temperature"]["value"].as_f64().unwrap() - HEATER).abs() < 1e-9, "{r}");
let probe = |s: &mut Session| s.execute("solve.probe", &json!({"step": "Heat", "at": [[4.0, 50.0, 20.0], [60.0, 4.0, 20.0]]})).unwrap();
let p = probe(&mut s);
let (upright, foot) = (p["nodes"][0]["temperature"].as_f64().unwrap(), p["nodes"][1]["temperature"].as_f64().unwrap());
assert!(upright > foot && foot > AMBIENT && upright <= HEATER, "{upright} {foot}");
let foot_hole = json!([{"body": "Bracket", "face": "F4:bore:side:0"}, {"body": "Bracket", "face": "F4:bore:side:1"}]);
s.execute("model.set.create", &json!({"name": "foot_hole", "geometry": foot_hole})).unwrap();
s.execute("model.boundary_condition.edit", &json!({"name": "Clamp", "type": "fixed", "target": {"set": "foot_hole"}})).unwrap();
s.execute("model.load.edit", &json!({"name": "Air", "type": "convection", "set": "skin", "film": 0.01, "sink": HEATER})).unwrap();
s.execute("solve.static", &json!({})).unwrap();
let p = probe(&mut s);
let grow = STEEL_EXPANSION * (HEATER - AMBIENT);
// The bore itself cannot grow, which holds back the foot's end, 16 mm from its edge, a
// little more than the upright's top, 32 mm away.
for (i, tolerance) in [(0, 0.03), (1, 0.06)] {
let node = &p["nodes"][i];
let (ux, expected) = (node["displacement"][0].as_f64().unwrap(), grow * (node["xyz"][0].as_f64().unwrap() - 40.0));
assert!((ux - expected).abs() < tolerance * expected.abs(), "{node}: {ux} against {expected}");
}
}
/// 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}");
}
/// The crank opens as one conforming structured hex20 mesh: the
/// half's seven cells, every cut merged node for node (no ties), the half and its mirror
/// image alike and joined on the symmetry plane, the spindle and pedal faces found on both
/// halves (the image's faces carry the names of the half they are the image of), and the
/// quality floor: scaled Jacobian at least 0.3, edge ratio at most 10.
#[test]
fn the_crank_opens_as_one_structured_mesh_of_two_alike_halves() {
let (arm, mirror) = (find("crank-arm").unwrap(), find("crank-mirror").unwrap());
let prepared = |x: &Sample| x.prepare.map(|f| f());
assert!(
arm.design == mirror.design && prepared(arm) == prepared(mirror) && (arm.script)(&[]) == (mirror.script)(&[]),
"one model under two names"
);
{
let name = "crank-arm";
let mut s = open(name);
let bodies: Vec<String> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
let mut cells: Vec<String> = CRANK_CELLS.iter().map(|c| c.to_string()).collect();
cells.push("CrankArm/b".into());
assert_eq!(bodies, cells);
assert!(s.model.ties().is_empty(), "every cut merged");
let elements = s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Hex20).count();
assert_eq!(elements, s.model.elements().len());
let mut set = |name: &str, bodies: &[&str]| {
s.execute("model.set.create", &json!({"name": name, "geometry": bodies.iter().map(|b| json!({"body": b})).collect::<Vec<_>>()}))
.unwrap();
s.model.resolve_set(name).unwrap()
};
let (a, b) = (set("half_a", &CRANK_CELLS), set("half_b", &["CrankArm/b"]));
assert_eq!(a.elements.len(), b.elements.len());
assert_eq!(a.elements.len() + b.elements.len(), elements);
assert_eq!(a.faces.len(), b.faces.len(), "the halves' skins are alike");
let shared = a.nodes.iter().filter(|n| b.nodes.binary_search(n).is_ok()).count();
assert!(shared > 100, "{shared}");
assert_eq!(a.nodes.len() + b.nodes.len() - shared, s.model.nodes().len());
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements, "{q}");
let metric = |m: &str, k: &str| q["metrics"][m][k].as_f64().unwrap();
assert!(metric("scaled_jacobian", "min") >= 0.3, "{q}");
assert!(metric("edge_ratio", "max") <= 10.0, "{q}");
let members = |set: &str| s.model.sets().get(set).map_or(Vec::new(), |x| x.geometry.iter().map(|g| g.body.clone()).collect::<Vec<_>>());
for set in ["spindle", "pedal"] {
let bodies = members(set);
assert!(bodies.iter().any(|b| b.starts_with("CrankArm/a/")) && bodies.iter().any(|b| b == "CrankArm/b"), "{set}: {bodies:?}");
assert!(!s.model.resolve_set(set).unwrap().faces.is_empty(), "{set}");
}
let faces = |set: &str| s.model.sets().get(set).map_or(0, |x| x.geometry.len());
assert_eq!((faces("spindle"), faces("pedal")), (10, 4), "{name}");
}
}
/// Solved, the crank's root bends as a beam: σxx on the arm's sides at x = 29 mm (35.6 mm
/// wide, 16 thick, 141 mm from the pedal) within 3 % of M/Z = 1500 · 141 / (16 · 35.6² / 6) =
/// 62.6 MPa, equal and opposite on the two mirror-image sides, and the spindle carries the
/// whole load.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_crank_arm_root_bends_as_the_beam_formula_says() {
let mut s = open("crank-arm");
s.execute("solve.run", &json!({})).unwrap();
let half_width: f64 = 19.0 - 7.0 * 29.0 / 170.0;
let probe = s.execute("solve.probe", &json!({"step": "Pedal", "at": [[29.0, -half_width, 8.0], [29.0, half_width, 8.0]]})).unwrap();
let sxx = |i: usize| probe["nodes"][i]["stress"][0].as_f64().unwrap();
let hand = PEDAL_LOAD * (170.0 - 29.0) / (16.0 * (2.0 * half_width).powi(2) / 6.0);
for (i, sign) in [(0, -1.0), (1, 1.0)] {
assert!((sign * sxx(i) - hand).abs() < 0.03 * hand, "{} against {hand} MPa: {probe}", sxx(i));
}
assert!((sxx(0) + sxx(1)).abs() < 0.01 * hand, "{probe}");
let reaction = s.execute("results.reactions", &json!({"step": "Pedal", "sets": ["spindle"]})).unwrap();
assert!((reaction["sets"][0]["force"][1].as_f64().unwrap() - PEDAL_LOAD).abs() < 1e-6, "{reaction}");
}
/// The conformity of `s`'s mesh ([`fieldcraft_mesh::quality::conformity`]).
fn conformity(s: &Session) -> fieldcraft_mesh::quality::Conformity {
let nodes: Vec<(u32, [f64; 3])> = s.model.nodes().iter().map(|(l, n)| (*l, n.xyz)).collect();
let elements = s.model.elements().iter().map(|(l, e)| (*l, e.kind, e.nodes.as_slice()));
fieldcraft_mesh::quality::conformity(&nodes, elements, 1e-6).unwrap()
}
/// The crank arm's hex20 mesh has no crack (#260): its free faces close up across the cells
/// and the mirror plane, no two nodes coincide and every element edge has one mid-side node.
#[test]
fn the_crank_arm_hex20_mesh_is_conforming() {
let s = open("crank-arm");
let c = conformity(&s);
assert!(c.is_conforming(), "{:?} {:?} {:?}", c.open_edges.first(), c.coincident.first(), c.midside_mismatches.first());
}
#[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));
}
}