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
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 23:17:20 -04:00
1 parent 73987bdbae
commit 90948833e0
6 files changed
+188 -118

No files matched your search

+152 -92
View File
@@ -103,17 +103,17 @@ pub const SAMPLES: &[Sample] = &[
Sample {
name: "crank-mirror",
label: "Mirrored Crank Arm",
about: "The crank arm cut on its symmetry plane, one half swept all-hex (hex20) and mirrored into one conforming mesh; loaded as 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_mirror_prepare),
script: crank_mirror,
prepare: Some(crank_prepare),
script: crank_arm,
},
Sample {
name: "crank-arm",
label: "Bicycle Crank Arm",
about: "An aluminium bicycle crank swept all-hex (hex20): square bore fixed, a rider's weight on the pedal, von Mises",
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: None,
prepare: Some(crank_prepare),
script: crank_arm,
},
];
@@ -497,35 +497,101 @@ 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;
/// [`CRANK_ARM`] swept all-hex (hex20 of 3 mm), 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 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 {
crank_model(bodies, vec![json!({"command": "mesh.hex.generate", "params": {"size": 3, "order": 2, "method": "extrude"}})])
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)
}
/// [`CRANK_ARM`] cut on its symmetry plane (`Sym`, y = 0; partition `Half`) by
/// [`crank_mirror_prepare`]: the half `CrankArm/b` (y > 0) swept all-hex (hex20 of 3 mm) and
/// mirrored onto `CrankArm/a`, one conforming mesh; then the model of [`crank_arm`].
pub fn crank_mirror(bodies: &[Shape]) -> Value {
crank_model(
bodies,
vec![
json!({"command": "mesh.hex.generate", "params": {"body": "CrankArm/b", "size": 3, "order": 2, "method": "extrude"}}),
json!({"command": "mesh.mirror", "params": {"plane": "Sym", "bodies": ["CrankArm/b"]}}),
],
)
/// 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 and the partition by it.
fn crank_mirror_prepare() -> Value {
json!([
{"command": "geometry.datum", "params": {"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}}},
{"command": "geometry.partition", "params": {"name": "Half", "datum": "Sym"}},
])
/// 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
@@ -793,18 +859,65 @@ mod tests {
assert!(r["max_von_mises"]["value"].as_f64().unwrap() > 200.0, "{r}");
}
/// The crank arm opens swept all-hex20 with every bore face found, and its 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, the spindle carrying the
/// 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");
let elements = s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Hex20).count();
assert_eq!(elements, s.model.elements().len());
let faces = |set: &str| s.model.sets().get(set).map_or(0, |x| x.geometry.len());
assert_eq!((faces("spindle"), faces("pedal")), (8, 2));
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();
@@ -813,6 +926,7 @@ mod tests {
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}");
}
@@ -824,69 +938,15 @@ mod tests {
fieldcraft_mesh::quality::conformity(&nodes, elements, 1e-6).unwrap()
}
/// The crank arm swept all-hex20 has no crack (#260): its free faces close up, no two nodes
/// coincide and every element edge has one mid-side node.
/// 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);
// The count differs between platforms (#115: 7274 on macOS, 7334 on Linux), so check
// that the mesh is all hex20 rather than its size.
assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Hex20));
assert!(c.is_conforming(), "{:?} {:?} {:?}", c.open_edges.first(), c.coincident.first(), c.midside_mismatches.first());
}
/// The mirrored crank opens as one conforming all-hex20 mesh: its two halves alike, joined
/// on the symmetry plane, every element valid, and the spindle and pedal faces found on both
/// halves (the image's faces carry the names of the half they are the image of).
#[test]
fn the_mirrored_crank_opens_as_one_mesh_of_two_alike_halves() {
let mut s = open("crank-mirror");
let elements = s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Hex20).count();
assert_eq!(elements, s.model.elements().len());
let mut half = |body: &str| {
let set = format!("half_{}", body.replace('/', "_"));
s.execute("model.set.create", &json!({"name": set, "geometry": [{"body": body}]})).unwrap();
s.model.resolve_set(&set).unwrap()
};
let (a, b) = (half("CrankArm/a"), half("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");
// Joined on the plane: nodes shared by both halves, none duplicated there.
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 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 == "CrankArm/a") && bodies.iter().any(|b| b == "CrankArm/b"), "{set}: {bodies:?}");
assert!(!s.model.resolve_set(set).unwrap().faces.is_empty(), "{set}");
}
}
/// Solved, the mirrored crank bends as the swept whole one does (σxx at the root within 3 % of
/// M/Z) and the spindle carries the whole load.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_mirrored_crank_bends_as_the_beam_formula_says() {
let mut s = open("crank-mirror");
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));
}
// Mirror images: the two sides' bending stresses are equal and opposite.
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}");
}
#[test]
fn the_drop_test_opens_ready_to_solve() {
let s = open("drop-test");