Files
OneCAD-Simulation/crates/solver/tests/structural.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

592 lines
29 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.
//! Analytic verification of the structural elements: shell patch tests (membrane and bending,
//! quad4 and tri3, in a tilted plane), the simply supported plate under uniform pressure against
//! Navier's series (deflection and first frequency), cantilever beams against Timoshenko and
//! Euler–Bernoulli theory (deflection and frequencies), a portal frame against the
//! slope-deflection hand calculation, and mixed meshes of solids with shells and rods.
//!
//! Every expected value is a closed form or a converged series; nothing comes from another
//! program.
use std::collections::BTreeMap;
use fieldcraft_model::{
BeamProfile, BoundaryCondition, Distribution, Elastic, ElementFace, ElementKind, Formulation, Load, Material, Model, Procedure, Section,
SectionKind, Set, Step, Target,
};
use fieldcraft_solver::{FrequencyOptions, Limits, MassKind, StaticResult, solve_frequency, solve_static};
/// A model under construction: nodes and elements numbered from 1, one material, one step.
struct Build {
model: Model,
xyz: BTreeMap<u32, [f64; 3]>,
elements: u32,
}
impl Build {
fn new(elastic: Elastic, density: f64, procedure: Procedure) -> Build {
let mut model = Model::default();
model.add_material("Mat", Material { elastic: Some(elastic), density: Some(density), ..Material::default() }).unwrap();
model.add_step(Step::new("Step", procedure), None).unwrap();
Build { model, xyz: BTreeMap::new(), elements: 0 }
}
fn node(&mut self, label: u32, xyz: [f64; 3]) {
self.model.set_node(label, xyz).unwrap();
self.xyz.insert(label, xyz);
}
fn element(&mut self, kind: ElementKind, nodes: Vec<u32>) -> u32 {
self.elements += 1;
self.model.set_element(self.elements, kind, nodes).unwrap();
self.elements
}
fn section(&mut self, name: &str, elements: Vec<u32>, kind: SectionKind) {
self.model.add_set(name, Set { elements, ..Set::default() }).unwrap();
self.model.add_section(name, Section { set: name.into(), material: "Mat".into(), kind, formulation: Formulation::default() }).unwrap();
}
fn nodes_where(&self, f: impl Fn([f64; 3]) -> bool) -> Vec<u32> {
self.xyz.iter().filter(|(_, x)| f(**x)).map(|(l, _)| *l).collect()
}
/// A boundary condition on the nodes where `f` holds: `values` per direction (None = free).
fn hold(&mut self, name: &str, values: [Option<f64>; 6], f: impl Fn([f64; 3]) -> bool) {
let nodes = self.nodes_where(f);
self.hold_nodes(name, values, nodes);
}
fn hold_nodes(&mut self, name: &str, values: [Option<f64>; 6], nodes: Vec<u32>) {
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
self.model.add_boundary_condition(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values }).unwrap();
self.model.set_boundary_condition_active("Step", name, true).unwrap();
}
fn load(&mut self, name: &str, load: Load) {
self.model.add_load(name, load).unwrap();
self.model.set_load_active("Step", name, true).unwrap();
}
fn force(&mut self, name: &str, nodes: Vec<u32>, vector: [f64; 3]) {
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
self.load(name, Load::Force { target: Target::Set(name.into()), vector, distribution: Distribution::Total });
}
/// A structured surface mesh of n[0] × n[1] cells on the map of [0, 1]², quadrilaterals or
/// two triangles per cell, starting at node label `base`; returns the elements.
fn surface(&mut self, n: [usize; 2], kind: ElementKind, base: u32, map: impl Fn(f64, f64) -> [f64; 3]) -> Vec<u32> {
let label = |i: usize, j: usize| base + (i + (n[0] + 1) * j) as u32;
for j in 0..=n[1] {
for i in 0..=n[0] {
self.node(label(i, j), map(i as f64 / n[0] as f64, j as f64 / n[1] as f64));
}
}
let mut out = Vec::new();
for j in 0..n[1] {
for i in 0..n[0] {
let (a, b, c, d) = (label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1));
if kind == ElementKind::Quad4 {
out.push(self.element(kind, vec![a, b, c, d]));
} else {
out.push(self.element(kind, vec![a, b, c]));
out.push(self.element(kind, vec![a, c, d]));
}
}
}
out
}
fn solve(&self) -> StaticResult {
solve_static(&self.model, None, &Limits::default()).unwrap()
}
}
fn at(r: &StaticResult, label: u32) -> usize {
r.nodes.binary_search(&label).unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
const FREE: Option<f64> = None;
// ---------------------------------------------------------------------------------------------
// Shell patch tests
// ---------------------------------------------------------------------------------------------
/// The distorted patch of five quadrilaterals in a 0.24 × 0.12 rectangle (the irregular patch of
/// R. H. MacNeal, R. L. Harder, *A proposed standard set of problems to test finite element
/// accuracy*, Finite Elements in Analysis and Design 1(1):3–20, 1985), in local plane
/// coordinates; the four inner nodes are 5–8.
const PATCH: [[f64; 2]; 8] = [[0.0, 0.0], [0.24, 0.0], [0.24, 0.12], [0.0, 0.12], [0.04, 0.02], [0.18, 0.03], [0.16, 0.08], [0.08, 0.08]];
/// Thick enough that membrane and bending stiffness are within a few orders of magnitude, so the
/// comparison measures the elements and not the round-off of an ill-conditioned system.
const PATCH_THICKNESS: f64 = 0.02;
const PATCH_QUADS: [[u32; 4]; 5] = [[1, 2, 6, 5], [2, 3, 7, 6], [3, 4, 8, 7], [4, 1, 5, 8], [5, 6, 7, 8]];
/// A tilted plane: local (x, y) → global, with its axes.
fn tilted(p: [f64; 2]) -> [f64; 3] {
let (e1, e2) = axes();
[0, 1, 2].map(|k| 1.0 + p[0] * e1[k] + p[1] * e2[k])
}
fn axes() -> ([f64; 3], [f64; 3]) {
let e1 = [0.6, 0.8, 0.0];
let (c, s) = (0.5f64.sqrt(), 0.5f64.sqrt());
// e2 = cos · (−0.8, 0.6, 0) + sin · z.
let e2 = [-0.8 * c, 0.6 * c, s];
(e1, e2)
}
fn normal() -> [f64; 3] {
let (a, b) = axes();
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Local displacement (u, v, w) and rotations (θx, θy, θz) to global 6-vector.
fn to_global(local: [f64; 6]) -> [f64; 6] {
let (e1, e2) = axes();
let e3 = normal();
let g = |a: [f64; 3]| [0, 1, 2].map(|k| a[0] * e1[k] + a[1] * e2[k] + a[2] * e3[k]);
let t = g([local[0], local[1], local[2]]);
let r = g([local[3], local[4], local[5]]);
[t[0], t[1], t[2], r[0], r[1], r[2]]
}
/// The patch with a prescribed exact field on its boundary nodes 1–4: returns the worst nodal
/// error of the inner nodes (relative to the field's size) and the solution.
fn patch(kind: ElementKind, field: impl Fn([f64; 2]) -> [f64; 6]) -> (f64, StaticResult, Build) {
let mut b = Build::new(Elastic::Isotropic { young: 1e6, poisson: 0.25 }, 1.0, Procedure::Static {});
for (i, p) in PATCH.iter().enumerate() {
b.node(i as u32 + 1, tilted(*p));
}
let mut elements = Vec::new();
for q in PATCH_QUADS {
if kind == ElementKind::Quad4 {
elements.push(b.element(kind, q.to_vec()));
} else {
elements.push(b.element(kind, vec![q[0], q[1], q[2]]));
elements.push(b.element(kind, vec![q[0], q[2], q[3]]));
}
}
b.section("shell", elements, SectionKind::Shell { thickness: PATCH_THICKNESS, offset: 0.0, points: 5 });
for (i, p) in PATCH.iter().enumerate().take(4) {
let g = to_global(field(*p));
b.hold_nodes(&format!("edge{i}"), g.map(Some), vec![i as u32 + 1]);
}
let r = b.solve();
let size = PATCH.iter().flat_map(|p| field(*p)).fold(0.0f64, |m, v| m.max(v.abs()));
let mut worst = 0.0f64;
for (i, p) in PATCH.iter().enumerate().skip(4) {
let want = to_global(field(*p));
let k = at(&r, i as u32 + 1);
let got = [r.displacement[k], r.rotation[k]].concat();
for c in 0..6 {
worst = worst.max((got[c] - want[c]).abs() / size);
}
}
(worst, r, b)
}
#[test]
fn shell_membrane_patch_test() {
// u = 10⁻³ (x + y/2), v = 10⁻³ (y + x/2): constant strain, no in-plane rotation.
let field = |p: [f64; 2]| [1e-3 * (p[0] + p[1] / 2.0), 1e-3 * (p[1] + p[0] / 2.0), 0.0, 0.0, 0.0, 0.0];
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
let (err, r, _) = patch(kind, field);
// σ in local axes: E/(1 − ν²) [[1, ν], [ν, 1]] ε, τ = G γ.
let (e, nu) = (1e6, 0.25);
let c = e / (1.0 - nu * nu);
let (sx, sy, txy) = (c * (1e-3 + nu * 1e-3), c * (1e-3 + nu * 1e-3), e / (2.0 * (1.0 + nu)) * 1e-3);
let mut stress_err = 0.0f64;
for s in &r.shells {
let [a1, a2, _] = s.stress.axes;
for layer in [&s.stress.bottom, &s.stress.middle, &s.stress.top] {
for g in layer {
// Back to the patch's local axes: σ_ab = a·S·b.
let t = [[g[0], g[3], g[4]], [g[3], g[1], g[5]], [g[4], g[5], g[2]]];
let (e1, e2) = axes();
let p = |a: [f64; 3], b: [f64; 3]| (0..3).map(|i| (0..3).map(|j| a[i] * t[i][j] * b[j]).sum::<f64>()).sum::<f64>();
stress_err = stress_err.max(((p(e1, e1) - sx).abs()).max((p(e2, e2) - sy).abs()).max((p(e1, e2) - txy).abs()) / sx);
}
}
assert!(a1.iter().chain(&a2).all(|v| v.is_finite()));
}
eprintln!("membrane patch {kind:?}: displacement error {err:.1e}, stress error {stress_err:.1e}");
assert!(err < 1e-9 && stress_err < 1e-9, "{kind:?}: {err:e} {stress_err:e}");
}
}
#[test]
fn shell_bending_patch_test() {
// w = 10⁻³ (x² + x y + y²)/2 with θx = w,y and θy = −w,x: constant curvature, no shear.
let k = 1e-3;
let field = |p: [f64; 2]| {
let [x, y] = p;
[0.0, 0.0, k * (x * x + x * y + y * y) / 2.0, k * (x / 2.0 + y), -k * (x + y / 2.0), 0.0]
};
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
let (err, r, _) = patch(kind, field);
// κx = θy,x = −k, κy = −θx,y = −k, κxy = θy,y − θx,x = −k.
let (e, nu, t) = (1e6, 0.25, PATCH_THICKNESS);
let d = e * t * t * t / (12.0 * (1.0 - nu * nu));
let want = [d * (-k - nu * k), d * (-k - nu * k), d * (1.0 - nu) / 2.0 * -k];
let mut moment_err = 0.0f64;
for s in &r.shells {
// Local element axes may differ from the patch's in-plane axes by a rotation in the
// plane; the invariants Mx + My and Mx My − Mxy² do not.
for f in &s.stress.forces {
let (tr, det) = (f[3] + f[4], f[3] * f[4] - f[5] * f[5]);
let (wtr, wdet) = (want[0] + want[1], want[0] * want[1] - want[2] * want[2]);
moment_err = moment_err.max(rel(tr, wtr)).max(rel(det, wdet)).max(f[6].abs().max(f[7].abs()) / wtr.abs());
}
}
eprintln!("bending patch {kind:?}: displacement error {err:.1e}, moment error {moment_err:.1e}");
assert!(err < 1e-9 && moment_err < 1e-8, "{kind:?}: {err:e} {moment_err:e}");
}
}
// ---------------------------------------------------------------------------------------------
// Simply supported plate (Navier)
// ---------------------------------------------------------------------------------------------
/// Navier's series for the centre deflection of a simply supported square plate under uniform
/// pressure: w = 16 q a⁴/(π⁶ D) Σ_{m, n odd} (−1)^((m+n)/2 − 1) / (m n (m² + n²)²)
/// (S. Timoshenko, S. Woinowsky-Krieger, *Theory of Plates and Shells*, 2nd ed., McGraw-Hill
/// 1959, §30).
fn navier(q: f64, a: f64, d: f64) -> f64 {
let mut s = 0.0;
for m in (1..400).step_by(2) {
for n in (1..400).step_by(2) {
let (mf, nf) = (f64::from(m), f64::from(n));
let sign = if ((m + n) / 2 - 1) % 2 == 0 { 1.0 } else { -1.0 };
s += sign / (mf * nf * (mf * mf + nf * nf).powi(2));
}
}
16.0 * q * a.powi(4) / (std::f64::consts::PI.powi(6) * d) * s
}
/// A quarter of a simply supported square plate (side a, edges x = 0 and y = 0 held, symmetry
/// on x = y = a/2), n × n cells, under pressure q or for its modes.
fn plate(kind: ElementKind, n: usize, procedure: Procedure) -> Build {
let (a, t, e, nu, rho) = (1.0, 0.01, 1e7, 0.3, 1.0);
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: nu }, rho, procedure);
let elements = b.surface([n, n], kind, 1, |u, v| [u * a / 2.0, v * a / 2.0, 0.0]);
b.section("plate", elements.clone(), SectionKind::Shell { thickness: t, offset: 0.0, points: 5 });
let on = |x: f64, c: f64| (x - c).abs() < 1e-9;
// Hard simple support: w and the twist along the edge held.
b.hold("x0", [FREE, FREE, Some(0.0), Some(0.0), FREE, FREE], |p| on(p[0], 0.0));
b.hold("y0", [FREE, FREE, Some(0.0), FREE, Some(0.0), FREE], |p| on(p[1], 0.0));
b.hold("xs", [Some(0.0), FREE, FREE, FREE, Some(0.0), Some(0.0)], |p| on(p[0], a / 2.0));
b.hold("ys", [FREE, Some(0.0), FREE, Some(0.0), FREE, Some(0.0)], |p| on(p[1], a / 2.0));
if matches!(b.model.steps()[0].procedure, Procedure::Static {}) {
let faces = elements.iter().map(|e| ElementFace { element: *e, face: 1 }).collect();
b.model.add_set("top", Set { faces, ..Set::default() }).unwrap();
b.load("q", Load::Pressure { set: "top".into(), magnitude: 1.0 });
}
b
}
#[test]
fn simply_supported_plate_deflection_and_frequency() {
let (a, t, e, nu, rho) = (1.0, 0.01, 1e7, 0.3, 1.0);
let d = e * t * t * t / (12.0 * (1.0 - nu * nu));
let w = navier(1.0, a, d);
let f11 = std::f64::consts::PI * 2.0 / (a * a) * (d / (rho * t)).sqrt() / 2.0;
for kind in [ElementKind::Quad4, ElementKind::Tri3] {
let mut errors = Vec::new();
for n in [4, 8, 16] {
let b = plate(kind, n, Procedure::Static {});
let r = b.solve();
let centre = b.nodes_where(|p| (p[0] - 0.5).abs() < 1e-9 && (p[1] - 0.5).abs() < 1e-9)[0];
let got = -r.displacement[at(&r, centre)][2];
assert!((r.reaction_total[2] + r.applied_total[2]).abs() < 1e-9 * r.applied_total[2].abs());
let b = plate(kind, n, Procedure::Frequency { modes: 1, min_hz: None, max_hz: None });
let fr = solve_frequency(&b.model, None, &FrequencyOptions::default(), &Limits::default()).unwrap();
errors.push((n, rel(got, w), rel(fr.modes[0].frequency, f11)));
}
eprintln!("plate {kind:?} vs Navier w = {w:.6e}, f11 = {f11:.6}: (cells, deflection error, frequency error) {errors:.5?}");
let (_, dw, df) = errors[2];
assert!(dw < 0.01 && df < 0.01, "{kind:?} {errors:?}");
assert!(errors[0].1 > errors[2].1, "converges: {errors:?}");
}
}
// ---------------------------------------------------------------------------------------------
// Beams
// ---------------------------------------------------------------------------------------------
/// A straight cantilever along a skew direction, `n` beam elements, clamped at node 1.
fn cantilever(n: usize, length: f64, profile: BeamProfile, procedure: Procedure) -> (Build, [f64; 3], [f64; 3]) {
let dir = [2.0 / 3.0, 2.0 / 3.0, 1.0 / 3.0];
let orientation = [0.0, 0.0, 1.0];
let mut b = Build::new(Elastic::Isotropic { young: 210e3, poisson: 0.3 }, 7.85e-9, procedure);
for i in 0..=n {
let s = length * i as f64 / n as f64;
b.node(i as u32 + 1, dir.map(|c| c * s));
}
let elements: Vec<u32> = (1..=n as u32).map(|i| b.element(ElementKind::Beam2, vec![i, i + 1])).collect();
b.section("beam", elements, SectionKind::Beam { profile, orientation });
b.hold_nodes("clamp", [Some(0.0); 6], vec![1]);
// Local y: the orientation made normal to the axis; local z = x × y.
let dot = dir[2];
let y = [0, 1, 2].map(|k| orientation[k] - dot * dir[k]);
let ny = (y[0] * y[0] + y[1] * y[1] + y[2] * y[2]).sqrt();
let y = y.map(|c| c / ny);
let z = [dir[1] * y[2] - dir[2] * y[1], dir[2] * y[0] - dir[0] * y[2], dir[0] * y[1] - dir[1] * y[0]];
(b, y, z)
}
#[test]
fn cantilever_beam_deflection_is_exact() {
// A stocky I beam, so shear deformation matters: δ = P L³/(3 E I) + P L/(G A_s) in each plane.
let profile = BeamProfile::I { height: 200.0, width: 100.0, web: 6.0, flange: 10.0 };
let length: f64 = 1000.0;
let p = fieldcraft_fem::beam::properties(&profile, 0.3);
let (e, g) = (210e3, 210e3 / 2.6);
for n in [1, 4] {
for (along_y, name) in [(true, "weak"), (false, "strong")] {
let (mut b, y, z) = cantilever(n, length, profile, Procedure::Static {});
let d = if along_y { y } else { z };
b.force("tip", vec![n as u32 + 1], d.map(|c| 1000.0 * c));
let r = b.solve();
let u = r.displacement[at(&r, n as u32 + 1)];
let got = u[0] * d[0] + u[1] * d[1] + u[2] * d[2];
let (i, a_s) = if along_y { (p.iz, p.shear_y) } else { (p.iy, p.shear_z) };
let want = 1000.0 * length.powi(3) / (3.0 * e * i) + 1000.0 * length / (g * a_s);
eprintln!(
"cantilever {n} element(s), {name} axis: tip {got:.6e} vs {want:.6e} (shear share {:.1}%)",
100.0 * 1000.0 * length / (g * a_s) / want
);
assert!(rel(got, want) < 1e-9, "{got} {want}");
// The clamp's moment and the root section force.
let m = r.reaction_moment[at(&r, 1)];
let mm = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
assert!(rel(mm, 1000.0 * length) < 1e-9, "{m:?}");
let root = &r.beams[0].forces.ends[0];
let (shear, moment) = if along_y { (root[1], root[5]) } else { (root[2], root[4]) };
assert!(rel(shear.abs(), 1000.0) < 1e-9 && rel(moment.abs(), 1000.0 * length) < 1e-9, "{root:?}");
}
}
}
#[test]
fn cantilever_beam_frequencies() {
// A slender pipe, L/r = 1000 (shear and rotary inertia below 10⁻⁴ in the third mode):
// Euler–Bernoulli ω_k = (β_k L)² √(E I / (ρ A L⁴)).
let profile = BeamProfile::Pipe { radius: 10.0, thickness: 1.0 };
let length: f64 = 10000.0;
let p = fieldcraft_fem::beam::properties(&profile, 0.3);
let base = (210e3 * p.iy / (7.85e-9 * p.area * length.powi(4))).sqrt() / (2.0 * std::f64::consts::PI);
let beta = [1.875_104_068_711_961, 4.694_091_132_974_175, 7.854_757_438_237_613];
for mass in [MassKind::Consistent, MassKind::Lumped] {
let (b, _, _) = cantilever(40, length, profile, Procedure::Frequency { modes: 6, min_hz: None, max_hz: None });
let r = solve_frequency(&b.model, None, &FrequencyOptions { mass, shift: None }, &Limits::default()).unwrap();
// The pipe bends alike in both planes: modes come in pairs.
let errors: Vec<f64> = beta.iter().enumerate().map(|(k, bl)| r.modes[2 * k].frequency / (bl * bl * base) - 1.0).collect();
eprintln!("cantilever frequencies ({mass:?}, 40 elements) vs Euler–Bernoulli: {errors:.5?}");
let mass_total = 7.85e-9 * p.area * length;
assert!(rel(r.total_mass, mass_total) < 1e-12, "{} {mass_total}", r.total_mass);
for (k, e) in errors.iter().enumerate() {
// Lumped mass converges from below, a few 10⁻³ low at 40 elements.
let tolerance = if mass == MassKind::Consistent { 5e-4 } else { 5e-3 };
assert!(e.abs() < tolerance, "mode {k}: {e}");
assert!(rel(r.modes[2 * k].frequency, r.modes[2 * k + 1].frequency) < 1e-6);
}
}
}
#[test]
fn portal_frame_matches_the_slope_deflection_solution() {
// Columns of height h, a beam of span l, fixed bases, a horizontal load H at the top.
// Slope-deflection with Timoshenko members (Przemieniecki 1968, §5.6): with
// a = E I_c / (h (1 + Φ_c)) and b = E I_b / (l (1 + Φ_b)), Φ = 12 E I / (G A_s L²), the end
// moments are M_ij = k [(4 + Φ) θ_i + (2 − Φ) θ_j − 6 Δ/L]; antisymmetric sway gives the joint
// rotation θ = 6 a Δ / (h (a (4 + Φ_c) + 6 b)), the storey shear
// a (6 θ − 12 Δ/h) = −H h / 2, and the base moment M = a ((2 − Φ_c) θ − 6 Δ/h). The columns'
// axial deformation is left out of the hand calculation: their shortening and lengthening
// under the overturning moment tilts the girder by about 2 H h² / (l² E A), about 2.6 · 10⁻³
// of θ here; sway and base moment feel it far less.
let (h, l, load) = (8000.0, 10000.0, 1000.0);
let column = BeamProfile::Rectangle { width: 100.0, height: 100.0 };
let girder = BeamProfile::Rectangle { width: 100.0, height: 200.0 };
let e = 210e3;
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.3 }, 7.85e-9, Procedure::Static {});
let n = 8;
// Nodes: left column 1..=n+1 (base to top), beam to the right column top, right column down.
let mut path = Vec::new();
for i in 0..=n {
path.push([0.0, 0.0, h * i as f64 / n as f64]);
}
for i in 1..=n {
path.push([l * i as f64 / n as f64, 0.0, h]);
}
for i in 1..=n {
path.push([l, 0.0, h - h * i as f64 / n as f64]);
}
for (i, x) in path.iter().enumerate() {
b.node(i as u32 + 1, *x);
}
let count = path.len() as u32;
let mut columns = Vec::new();
let mut beam = Vec::new();
for i in 1..count {
let el = b.element(ElementKind::Beam2, vec![i, i + 1]);
if (n as u32 + 1..2 * n as u32 + 1).contains(&i) { beam.push(el) } else { columns.push(el) }
}
// Both members bend in the x–z plane: their profile height (local z) must lie in it, so
// the orientation (local y, the width) is along global y.
b.section("columns", columns, SectionKind::Beam { profile: column, orientation: [0.0, 1.0, 0.0] });
b.section("girder", beam, SectionKind::Beam { profile: girder, orientation: [0.0, 1.0, 0.0] });
b.hold_nodes("bases", [Some(0.0); 6], vec![1, count]);
b.force("push", vec![n as u32 + 1], [load, 0.0, 0.0]);
let r = b.solve();
let g = e / 2.6;
let (pc, pb) = (fieldcraft_fem::beam::properties(&column, 0.3), fieldcraft_fem::beam::properties(&girder, 0.3));
let (phi_c, phi_b) = (12.0 * e * pc.iy / (g * pc.shear_z * h * h), 12.0 * e * pb.iy / (g * pb.shear_z * l * l));
let (a, bb) = (e * pc.iy / (h * (1.0 + phi_c)), e * pb.iy / (l * (1.0 + phi_b)));
let c = 6.0 * a / (h * (a * (4.0 + phi_c) + 6.0 * bb));
let sway = -load * h / 2.0 / (a * (6.0 * c - 12.0 / h));
let theta = c * sway;
let base = a * ((2.0 - phi_c) * theta - 6.0 * sway / h);
let top = r.displacement[at(&r, n as u32 + 1)][0];
let m = r.reaction_moment[at(&r, 1)][1];
let rot = r.rotation[at(&r, n as u32 + 1)][1];
eprintln!(
"portal frame: sway {top:.6e} vs {sway:.6e} ({:.1e}), joint rotation {rot:.6e} vs {theta:.6e} ({:.1e}), base moment {m:.6e} vs {:.6e} ({:.1e})",
rel(top, sway),
rel(rot.abs(), theta.abs()),
base.abs(),
rel(m.abs(), base.abs())
);
assert!(rel(top, sway) < 5e-4 && rel(rot.abs(), theta.abs()) < 3e-3 && rel(m.abs(), base.abs()) < 5e-4);
assert!((r.reaction_total[0] + load).abs() < 1e-9 * load);
}
// ---------------------------------------------------------------------------------------------
// Mixed meshes
// ---------------------------------------------------------------------------------------------
/// A hex8 column 1 × 1 × 2 (two elements) clamped at z = 0, with four rods of area 1/4 from its
/// top corners up to four loaded nodes: the elongation is the sum of both parts' (ν = 0).
#[test]
fn rods_continue_a_solid_column() {
let e = 1000.0;
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.0 }, 1.0, Procedure::Static {});
let corners = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
for z in 0..3 {
for (i, c) in corners.iter().enumerate() {
b.node(1 + i as u32 + 4 * z, [c[0], c[1], f64::from(z)]);
}
}
for (i, c) in corners.iter().enumerate() {
b.node(13 + i as u32, [c[0], c[1], 5.0]);
}
let hexes: Vec<u32> = (0..2).map(|k| b.element(ElementKind::Hex8, (1..=8).map(|n| n + 4 * k).collect())).collect();
let rods: Vec<u32> = (0..4).map(|i| b.element(ElementKind::Rod2, vec![9 + i, 13 + i])).collect();
b.section("column", hexes, SectionKind::Solid {});
b.section("rods", rods, SectionKind::Rod { area: 0.25 });
b.hold_nodes("base", [Some(0.0), Some(0.0), Some(0.0), FREE, FREE, FREE], vec![1, 2, 3, 4]);
// Rods carry no lateral stiffness: hold their ends sideways.
b.hold_nodes("guides", [Some(0.0), Some(0.0), FREE, FREE, FREE, FREE], vec![13, 14, 15, 16]);
b.force("pull", vec![13, 14, 15, 16], [0.0, 0.0, 4.0]);
let r = b.solve();
let want = 4.0 * 2.0 / (e * 1.0) + 4.0 * 3.0 / (e * 1.0);
for n in 13..=16 {
let got = r.displacement[at(&r, n)][2];
assert!(rel(got, want) < 1e-12, "{got} {want}");
}
let rods: Vec<_> = r.elements.iter().filter(|e| e.kind == ElementKind::Rod2).collect();
assert_eq!(rods.len(), 4);
assert!(rods.iter().flat_map(|e| &e.nodal).all(|s| rel(fieldcraft_fem::von_mises(s), 4.0) < 1e-12), "{rods:?}");
// Solid nodes have no rotations.
assert!(r.rotation.iter().all(|x| *x == [0.0; 3]));
}
/// A hex8 slab with a shell skin on its top face sharing the nodes, stretched by prescribed end
/// displacements: the reaction is E (A_solid + A_shell) ε (ν = 0, uniform strain in both).
#[test]
fn shell_skin_on_a_solid_slab() {
let (e, length, width, depth, skin) = (1000.0, 4.0, 2.0, 1.0, 0.1);
let mut b = Build::new(Elastic::Isotropic { young: e, poisson: 0.0 }, 1.0, Procedure::Static {});
let (nx, ny) = (4usize, 2usize);
let label = |i: usize, j: usize, k: usize| (1 + i + (nx + 1) * (j + (ny + 1) * k)) as u32;
for k in 0..2 {
for j in 0..=ny {
for i in 0..=nx {
b.node(label(i, j, k), [length * i as f64 / nx as f64, width * j as f64 / ny as f64, depth * k as f64]);
}
}
}
let mut hexes = Vec::new();
let mut quads = Vec::new();
for j in 0..ny {
for i in 0..nx {
let q = |k| vec![label(i, j, k), label(i + 1, j, k), label(i + 1, j + 1, k), label(i, j + 1, k)];
hexes.push(b.element(ElementKind::Hex8, [q(0), q(1)].concat()));
quads.push(b.element(ElementKind::Quad4, q(1)));
}
}
b.section("slab", hexes, SectionKind::Solid {});
b.section("skin", quads, SectionKind::Shell { thickness: skin, offset: 0.0, points: 5 });
let strain = 1e-3;
b.hold("left", [Some(0.0), Some(0.0), Some(0.0), FREE, FREE, FREE], |p| p[0] == 0.0);
b.hold("right", [Some(strain * length), Some(0.0), Some(0.0), FREE, FREE, FREE], |p| p[0] == length);
let r = b.solve();
let want = e * (width * depth + width * skin) * strain;
let pull: f64 = b.nodes_where(|p| p[0] == length).iter().map(|n| r.reaction[at(&r, *n)][0]).sum();
eprintln!("solid with shell skin: end reaction {pull:.9e} vs {want:.9e}");
assert!(rel(pull, want) < 1e-10, "{pull}");
let top = r.shells[0].stress.middle[0][0];
assert!(rel(top, e * strain) < 1e-10, "{top}");
}
// ---------------------------------------------------------------------------------------------
// Errors, not crashes
// ---------------------------------------------------------------------------------------------
#[test]
fn structural_models_that_cannot_be_solved_are_errors() {
// A beam whose orientation is along it.
let (mut b, _, _) = cantilever(2, 100.0, BeamProfile::Circle { radius: 1.0 }, Procedure::Static {});
b.model
.replace_section(
"beam",
Section {
set: "beam".into(),
material: "Mat".into(),
kind: SectionKind::Beam { profile: BeamProfile::Circle { radius: 1.0 }, orientation: [2.0, 2.0, 1.0] },
formulation: Formulation::default(),
},
)
.unwrap();
b.force("tip", vec![3], [1.0, 0.0, 0.0]);
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
assert!(err.to_string().contains("parallel"), "{err}");
// A quad8 shell is not supported yet; a degenerate quad4 is an error naming the element.
for (kind, nodes, want) in [(ElementKind::Quad8, 8, "not supported"), (ElementKind::Quad4, 4, "degenerate")] {
let mut b = Build::new(Elastic::Isotropic { young: 1.0, poisson: 0.0 }, 1.0, Procedure::Static {});
for i in 0..nodes {
b.node(i as u32 + 1, [f64::from(i as u8 % 2), 0.0, 0.0]);
}
let el = b.element(kind, (1..=nodes as u32).collect());
b.section("s", vec![el], SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 });
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
assert!(err.to_string().contains(want), "{kind:?}: {err}");
}
// A moment on the nodes of a rod: they have no rotations.
let mut b = Build::new(Elastic::Isotropic { young: 1.0, poisson: 0.0 }, 1.0, Procedure::Static {});
b.node(1, [0.0; 3]);
b.node(2, [1.0, 0.0, 0.0]);
let el = b.element(ElementKind::Rod2, vec![1, 2]);
b.section("r", vec![el], SectionKind::Rod { area: 1.0 });
b.hold_nodes("all", [Some(0.0); 6], vec![1]);
b.model.add_set("end", Set { nodes: vec![2], ..Set::default() }).unwrap();
b.load("twist", Load::Moment { target: Target::Set("end".into()), vector: [1.0, 0.0, 0.0], distribution: Distribution::Total });
let err = solve_static(&b.model, None, &Limits::default()).unwrap_err();
assert!(err.to_string().contains("rotations"), "{err}");
}