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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 23:17:20 -04:00
1 parent 73987bdbae
commit 90948833e0
6 files changed
+188 -118

No files matched your search

+1 -1
View File
@@ -106,7 +106,7 @@ against a closed form or a reference solution ([docs/examples.md](docs/examples.
</tr>
<tr>
<td width="33%" valign="top"><img src="docs/media/examples/bracket-hex.png" alt="Hex-meshed bracket" width="100%"><br><b>Hex-meshed bracket</b> (<code>bracket-hex</code>): the bracket split into 30 cells, meshed all-hex (hex20), von Mises</td>
<td width="33%" valign="top"><img src="docs/media/examples/crank-arm.png" alt="Bicycle crank arm" width="100%"><br><b>Bicycle crank arm</b> (<code>crank-arm</code>): an aluminium crank swept all-hex (hex20), a rider's weight on the pedal; root bending within 0.3 % of M/Z</td>
<td width="33%" valign="top"><img src="docs/media/examples/crank-arm.png" alt="Bicycle crank arm" width="100%"><br><b>Bicycle crank arm</b> (<code>crank-arm</code>): an aluminium crank meshed structured all-hex (hex20) from a partitioned half and its mirror image, a rider's weight on the pedal; root bending within 0.3 % of M/Z</td>
<td width="33%" valign="top"><i>No picture yet: the viewport does not draw flow results (M12).</i><br><b>Flow past a bracket</b> (<code>flow-past</code>): air past an L-bracket on the floor; lattice Boltzmann flow, the drag and the force on each face</td>
</tr>
</table>
+1 -1
View File
@@ -31,7 +31,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; sample `crank-mirror`; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: the half cut into seven cells, each swept structured, seeded to merge, and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
+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");
+34 -24
View File
@@ -114,42 +114,52 @@ clamp carries the whole 1 kN.
## Mirrored crank arm (`crank-mirror`)
![Von Mises stress on the mirrored crank arm: one swept half and its mirror image, one mesh](media/examples/crank-mirror.png)
![Von Mises stress on the crank arm from below: the pocket ringed by mapped rows, the half and its mirror image one mesh](media/examples/crank-mirror.png)
The crank arm of `crank-arm`, built from a half. A datum plane on its symmetry plane (`Sym`,
y = 0) partitions it (`Half`) into `CrankArm/a` and `CrankArm/b`; the half `CrankArm/b` is swept
all-hex (hex20 of 3 mm, 3 330 elements) and `mesh.mirror` reflects it onto `CrankArm/a`, joined
on the plane: 6 660 hex20 elements, 32 933 nodes, all valid (scaled Jacobian min 0.009, mean 0.91).
The image's faces carry `CrankArm/a`'s names, so the sets pick the square bore and the pedal hole
on both halves. Material, supports and load as `crank-arm`.
The model of `crank-arm` under the name of its mirror workflow (the sample kept so scripts and
links made with it still open): seen from below, where the pocket is. How the half is cut,
meshed and mirrored is told under `crank-arm`.
**Look at:** the two halves' meshes, mirror images of each other across the arm's middle (Mesh
workspace), and the datum plane `Sym` before solving.
**Look at:** the pocket from below: straight mapped rows along its sides and floor, and at each
end its corner fillets cut off by the diagonal partitions into three-sided triangle primitives;
the two halves, mirror images across the arm's middle.
**Check:** σxx on the arm's sides at x = 29 mm is −62.43 and +62.43 MPa, equal and opposite as
the halves are, against M/Z = 62.5 MPa (0.2 %); the pedal bore moves 0.75 mm down, as on the whole
swept crank; the spindle carries the whole 1500 N.
**Check:** as `crank-arm`.
## Bicycle crank arm (`crank-arm`)
![Von Mises stress on the all-hex crank arm, the arm root and the square bore carrying the most](media/examples/crank-arm.png)
![Von Mises stress on the structured all-hex crank arm, the arm root and the square bore carrying the most](media/examples/crank-arm.png)
A 170 mm aluminium crank built in SolidCraft (`crates/engine/samples/crank-arm.json`): a 16 mm
arm tapering from 38 to 24 mm, root arcs into 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). It is a stack of extrusions along z, so `mesh.hex.generate` sweeps it all-hex:
7 274 hex20 elements of 3 mm, all valid, scaled Jacobian min 0.17, mean 0.83. To keep it
sweepable the hex variant leaves out the pocket's floor fillet and the bosses' 1.5 mm chamfers,
and its root arcs meet the bosses about 20° off tangent (a tangent meeting leaves a zero-angle
cusp on the arm's top face, where any hex is degenerate). 6061-T6 (E = 68 900 MPa, ν = 0.33); the
square bore's faces fixed, 1500 N on the pedal hole in the crank's plane, across the arm (−y).
Linear static: about 4 s to open and 7 s to solve.
(R5 corners). It is a stack of extrusions along z, so it sweeps all-hex. To keep it sweepable the
hex variant leaves out the pocket's floor fillet and the bosses' 1.5 mm chamfers, and its root
arcs meet the bosses about 20° off tangent (a tangent meeting leaves a zero-angle cusp on the
arm's top face, where any hex is degenerate). 6061-T6 (E = 68 900 MPa, ν = 0.33); the square
bore's faces fixed, 1500 N on the pedal hole in the crank's plane, across the arm (−y). Linear
static: about 5 s to open and 7 s to solve.
**Look at:** the swept mesh (Prepare ▸ Mesh), eight layers through the arm, and the von Mises
contours: the bending stress grows along the arm toward the root; the peak, 129 MPa, sits at a
square-bore corner where the rigid clamp meets the free face, a support singularity.
The mesh is built as an analyst builds a structured one. A datum plane on the symmetry plane
(`Sym`, y = 0) partitions the crank (`Half`); the half `CrankArm/a` is cut into seven 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 two diagonals (`D1`, `D2`) through the centres
of the pocket's corner fillets, radial to them so they meet the pocket wall square. Every
cell's cross-section then splits into regions that map: the half ring round each bore (an
O-grid), four-sided blocks (transfinite), and the pocket's four corners, three-sided, as
triangle primitives. Exact edge seeds (`mesh.seed.edge`) give every cut the same divisions on
its two sides, so `mesh.cells` sweeps each cell (`extrude`, hex20 of 3 mm) and merges all six
cuts node for node, and `mesh.mirror` reflects the half onto `CrankArm/b`: 7 710 hex20
elements, 37 938 nodes, all valid, scaled Jacobian min 0.37, mean 0.92, edge ratio at most 7.0
(the swept crank before: min 0.17, its free-paved middle at the pocket the worst).
**Check:** σxx on the arm's sides at x = 29 mm is ±62.4 MPa against the beam formula
**Look at:** the mesh (Prepare ▸ Mesh): rows running straight along the arm, the O-grids round
the bores, eight layers through the arm, and the von Mises contours: the bending stress grows
along the arm toward the root; the peak, 145 MPa, sits at a square-bore corner where the rigid
clamp meets the free face, a support singularity.
**Check:** σxx on the arm's sides at x = 29 mm is −62.43 and +62.43 MPa, equal and opposite as
the mirror-image halves are, against the beam formula
M/Z = 1500 · 141 / (16 · 35.6² / 6) = 62.6 MPa (0.3 %); a 41 815-element tet10 solution of the
full part gives 62.3 MPa. The pedal bore moves 0.75 mm down (tet10: 0.70–0.75 mm; Euler–Bernoulli
with a rigid clamp: 0.58 mm, without shear or the boss's compliance), and the spindle carries the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

After

Width:  |  Height:  |  Size: 240 KiB