use crate::beam_stress::{ColumnEndCondition, CrossSection};
use crate::filament_db::MaterialProperties;
use crate::math::Fix128;
#[must_use]
pub(crate) fn radius_of_gyration_mm(section: &CrossSection) -> Fix128 {
let a = section.area_mm2();
let i = weak_axis_second_moment_mm4(section);
if a.is_zero() {
return Fix128::ZERO;
}
(i / a).sqrt()
}
#[must_use]
pub(crate) fn weak_axis_second_moment_mm4(section: &CrossSection) -> Fix128 {
let twelve = Fix128::from_int(12);
let cube = |v: Fix128| v * v * v;
let i_x = section.second_moment_of_area_mm4();
let i_y = match *section {
CrossSection::Rectangular {
width_mm,
height_mm,
} => height_mm * cube(width_mm) / twelve,
CrossSection::Circular { .. } | CrossSection::HollowCircular { .. } => i_x,
CrossSection::HollowRectangular {
outer_width_mm,
outer_height_mm,
wall_mm,
} => {
let inner_w = outer_width_mm - wall_mm.double();
let inner_h = outer_height_mm - wall_mm.double();
(outer_height_mm * cube(outer_width_mm) - inner_h * cube(inner_w)) / twelve
}
CrossSection::IBeam {
flange_width_mm,
height_mm,
flange_thickness_mm,
web_thickness_mm,
} => {
let web_h = height_mm - flange_thickness_mm.double();
(flange_thickness_mm.double() * cube(flange_width_mm) + web_h * cube(web_thickness_mm))
/ twelve
}
};
i_x.min(i_y)
}
#[must_use]
pub(crate) fn slenderness_ratio(
section: &CrossSection,
length_mm: Fix128,
end_condition: ColumnEndCondition,
) -> Fix128 {
let r = radius_of_gyration_mm(section);
if r.is_zero() {
return Fix128::ZERO;
}
end_condition.k_factor() * length_mm / r
}
#[must_use]
pub(crate) fn transition_slenderness(e_mpa: Fix128, sigma_y_mpa: Fix128) -> Fix128 {
if sigma_y_mpa.is_zero() {
return Fix128::ZERO;
}
let two_e = e_mpa.double();
let ratio = two_e / sigma_y_mpa;
Fix128::PI * ratio.sqrt()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BucklingRegime {
Johnson,
Euler,
Yielding,
}
#[must_use]
pub(crate) fn critical_stress_mpa(
slenderness: Fix128,
e_mpa: Fix128,
sigma_y_mpa: Fix128,
) -> (Fix128, BucklingRegime) {
if slenderness.is_zero() {
return (sigma_y_mpa, BucklingRegime::Yielding);
}
let lambda_t = transition_slenderness(e_mpa, sigma_y_mpa);
if slenderness >= lambda_t {
let l2 = slenderness * slenderness;
let sigma = Fix128::PI * Fix128::PI * e_mpa / l2;
(sigma, BucklingRegime::Euler)
} else if !sigma_y_mpa.is_zero() {
let l2 = slenderness * slenderness;
let pi2_e_4 = Fix128::PI * Fix128::PI * e_mpa * Fix128::from_int(4);
if pi2_e_4.is_zero() {
return (sigma_y_mpa, BucklingRegime::Yielding);
}
let reduction = sigma_y_mpa * sigma_y_mpa * l2 / pi2_e_4;
let sigma = if reduction < sigma_y_mpa {
sigma_y_mpa - reduction
} else {
Fix128::ZERO
};
(sigma, BucklingRegime::Johnson)
} else {
(Fix128::ZERO, BucklingRegime::Yielding)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColumnBucklingReport {
pub radius_of_gyration_mm: Fix128,
pub slenderness: Fix128,
pub transition_slenderness: Fix128,
pub critical_stress_mpa: Fix128,
pub critical_load_n: Fix128,
pub regime: BucklingRegime,
}
#[must_use]
pub fn analyze_column(
section: &CrossSection,
length_mm: Fix128,
end_condition: ColumnEndCondition,
material: &MaterialProperties,
) -> ColumnBucklingReport {
let e_mpa = material.youngs_modulus_gpa * Fix128::from_int(1000);
let sigma_y_mpa = material.yield_strength_mpa;
let r = radius_of_gyration_mm(section);
let lambda = slenderness_ratio(section, length_mm, end_condition);
let lambda_t = transition_slenderness(e_mpa, sigma_y_mpa);
let (sigma_cr, regime) = critical_stress_mpa(lambda, e_mpa, sigma_y_mpa);
let p_cr = sigma_cr * section.area_mm2();
ColumnBucklingReport {
radius_of_gyration_mm: r,
slenderness: lambda,
transition_slenderness: lambda_t,
critical_stress_mpa: sigma_cr,
critical_load_n: p_cr,
regime,
}
}
#[must_use]
pub(crate) fn plate_buckling_mpa(
e_mpa: Fix128,
poisson: Fix128,
thickness_mm: Fix128,
width_mm: Fix128,
k: Fix128,
) -> Fix128 {
if width_mm.is_zero() {
return Fix128::ZERO;
}
let nu2 = poisson * poisson;
let one_minus_nu2 = Fix128::ONE - nu2;
if one_minus_nu2.is_zero() {
return Fix128::ZERO;
}
let ratio = thickness_mm / width_mm;
let ratio2 = ratio * ratio;
let numer = k * Fix128::PI * Fix128::PI * e_mpa * ratio2;
numer / (Fix128::from_int(12) * one_minus_nu2)
}
pub(crate) fn snap_through_load_n(
e_mpa: Fix128,
section_area_mm2: Fix128,
rise_mm: Fix128,
span_mm: Fix128,
) -> Result<Fix128, SnapThroughError> {
if span_mm <= Fix128::ZERO {
return Err(SnapThroughError::NonPositiveSpan);
}
if e_mpa <= Fix128::ZERO || section_area_mm2 <= Fix128::ZERO {
return Err(SnapThroughError::NonPositiveStiffness);
}
if rise_mm < Fix128::ZERO {
return Err(SnapThroughError::NegativeRise);
}
let ratio = rise_mm / span_mm;
let ratio3 = ratio * ratio * ratio;
let coefficient = Fix128::from_int(16) / (Fix128::from_int(3) * Fix128::from_int(3).sqrt());
Ok(coefficient * e_mpa * section_area_mm2 * ratio3)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SnapThroughError {
NonPositiveSpan,
NonPositiveStiffness,
NegativeRise,
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: Fix128, b: Fix128, tol: Fix128) -> bool {
let d = if a > b { a - b } else { b - a };
d <= tol
}
#[test]
fn radius_of_gyration_rectangle() {
let s = CrossSection::Rectangular {
width_mm: Fix128::from_int(10),
height_mm: Fix128::from_int(20),
};
let r = radius_of_gyration_mm(&s);
let expected = Fix128::from_ratio(2887, 1000);
assert!(
approx_eq(r, expected, Fix128::from_ratio(1, 100)),
"got {}, expected ~2.887",
r.to_f32()
);
}
#[test]
fn radius_of_gyration_circle_is_d_over_4() {
let s = CrossSection::Circular {
diameter_mm: Fix128::from_int(8),
};
let r = radius_of_gyration_mm(&s);
assert!(approx_eq(
r,
Fix128::from_int(2),
Fix128::from_ratio(1, 100)
));
}
#[test]
fn slenderness_pin_pin_baseline() {
let s = CrossSection::Rectangular {
width_mm: Fix128::from_int(10),
height_mm: Fix128::from_int(20),
};
let lambda = slenderness_ratio(&s, Fix128::from_int(500), ColumnEndCondition::PinPin);
assert!(
approx_eq(lambda, Fix128::from_ratio(1732, 10), Fix128::from_int(1)),
"got {}, expected ~173.2",
lambda.to_f32()
);
}
#[test]
fn slenderness_cantilever_double_pinpin() {
let s = CrossSection::Circular {
diameter_mm: Fix128::from_int(10),
};
let l = Fix128::from_int(300);
let lambda_pin = slenderness_ratio(&s, l, ColumnEndCondition::PinPin);
let lambda_cant = slenderness_ratio(&s, l, ColumnEndCondition::Cantilever);
let ratio = lambda_cant / lambda_pin;
assert!(approx_eq(
ratio,
Fix128::from_int(2),
Fix128::from_ratio(1, 100)
));
}
#[test]
fn transition_slenderness_pla() {
let lambda_t = transition_slenderness(Fix128::from_int(3500), Fix128::from_int(50));
let expected = Fix128::from_ratio(3719, 100);
assert!(
approx_eq(lambda_t, expected, Fix128::ONE),
"got {}, expected ~37.2",
lambda_t.to_f32()
);
}
#[test]
fn euler_regime_slender_column() {
let e = Fix128::from_int(3500);
let sy = Fix128::from_int(50);
let (sigma, regime) = critical_stress_mpa(Fix128::from_int(200), e, sy);
assert_eq!(regime, BucklingRegime::Euler);
assert!(
approx_eq(
sigma,
Fix128::from_ratio(863, 1000),
Fix128::from_ratio(1, 10)
),
"got {}",
sigma.to_f32()
);
}
#[test]
fn johnson_regime_stocky_column() {
let e = Fix128::from_int(3500);
let sy = Fix128::from_int(50);
let (sigma, regime) = critical_stress_mpa(Fix128::from_int(20), e, sy);
assert_eq!(regime, BucklingRegime::Johnson);
assert!(
approx_eq(sigma, Fix128::from_ratio(427, 10), Fix128::ONE),
"got {}, expected ~42.7",
sigma.to_f32()
);
}
#[test]
fn at_transition_regimes_agree() {
let e = Fix128::from_int(3500);
let sy = Fix128::from_int(50);
let lambda_t = transition_slenderness(e, sy);
let (s_below, r_below) = critical_stress_mpa(lambda_t - Fix128::from_ratio(1, 10), e, sy);
let (s_above, r_above) = critical_stress_mpa(lambda_t + Fix128::from_ratio(1, 10), e, sy);
assert_eq!(r_below, BucklingRegime::Johnson);
assert_eq!(r_above, BucklingRegime::Euler);
let diff = if s_below > s_above {
s_below - s_above
} else {
s_above - s_below
};
assert!(diff < Fix128::from_int(2), "diff = {}", diff.to_f32());
}
#[test]
fn analyze_column_full_report_pla() {
let s = CrossSection::Rectangular {
width_mm: Fix128::from_int(10),
height_mm: Fix128::from_int(10),
};
let report = analyze_column(
&s,
Fix128::from_int(500),
ColumnEndCondition::PinPin,
&MaterialProperties::pla(),
);
assert!(report.slenderness > Fix128::from_int(150));
assert_eq!(report.regime, BucklingRegime::Euler);
assert!(report.critical_load_n > Fix128::ZERO);
}
#[test]
fn analyze_column_short_pla_is_johnson() {
let s = CrossSection::Rectangular {
width_mm: Fix128::from_int(10),
height_mm: Fix128::from_int(10),
};
let report = analyze_column(
&s,
Fix128::from_int(20),
ColumnEndCondition::PinPin,
&MaterialProperties::pla(),
);
assert_eq!(report.regime, BucklingRegime::Johnson);
}
#[test]
fn plate_buckling_scales_with_thickness_squared() {
let e = Fix128::from_int(3500);
let nu = Fix128::from_ratio(35, 100);
let s1 = plate_buckling_mpa(
e,
nu,
Fix128::from_int(1),
Fix128::from_int(20),
Fix128::from_int(4),
);
let s2 = plate_buckling_mpa(
e,
nu,
Fix128::from_int(2),
Fix128::from_int(20),
Fix128::from_int(4),
);
let ratio = s2 / s1;
assert!(approx_eq(
ratio,
Fix128::from_int(4),
Fix128::from_ratio(1, 100)
));
}
#[test]
fn plate_buckling_scales_with_width_inverse_squared() {
let e = Fix128::from_int(3500);
let nu = Fix128::from_ratio(35, 100);
let s_narrow = plate_buckling_mpa(
e,
nu,
Fix128::from_int(1),
Fix128::from_int(10),
Fix128::from_int(4),
);
let s_wide = plate_buckling_mpa(
e,
nu,
Fix128::from_int(1),
Fix128::from_int(20),
Fix128::from_int(4),
);
let ratio = s_narrow / s_wide;
assert!(approx_eq(
ratio,
Fix128::from_int(4),
Fix128::from_ratio(1, 100)
));
}
#[test]
fn plate_buckling_matches_timoshenko_closed_form() {
let got = plate_buckling_mpa(
Fix128::from_int(3500),
Fix128::from_ratio(35, 100),
Fix128::from_int(1),
Fix128::from_int(20),
Fix128::from_int(4),
)
.to_f64();
let pi = core::f64::consts::PI;
let want =
4.0 * pi * pi * 3500.0 / (12.0 * (1.0 - 0.35 * 0.35)) * (1.0 / 20.0) * (1.0 / 20.0);
assert!(((got - want) / want).abs() < 1e-9, "got {got}, want {want}");
}
#[test]
fn plate_buckling_degenerate_inputs_return_zero() {
let e = Fix128::from_int(3500);
let nu = Fix128::from_ratio(35, 100);
let one = Fix128::ONE;
let k = Fix128::from_int(4);
assert_eq!(
plate_buckling_mpa(e, nu, one, Fix128::ZERO, k),
Fix128::ZERO
);
assert_eq!(
plate_buckling_mpa(e, one, one, Fix128::from_int(20), k),
Fix128::ZERO
);
assert_eq!(
plate_buckling_mpa(e, Fix128::NEG_ONE, one, Fix128::from_int(20), k),
Fix128::ZERO
);
assert_eq!(
plate_buckling_mpa(e, nu, Fix128::ZERO, Fix128::from_int(20), k),
Fix128::ZERO
);
assert_eq!(
plate_buckling_mpa(Fix128::ZERO, nu, one, Fix128::from_int(20), k),
Fix128::ZERO
);
}
#[test]
fn snap_through_degenerate_inputs() {
let e = Fix128::from_int(3500);
let a = Fix128::from_int(10);
let h = Fix128::from_int(2);
let l = Fix128::from_int(20);
let neg = Fix128::from_int(-1);
assert_eq!(snap_through_load_n(e, a, Fix128::ZERO, l), Ok(Fix128::ZERO));
assert_eq!(
snap_through_load_n(e, Fix128::ZERO, h, l),
Err(SnapThroughError::NonPositiveStiffness)
);
assert_eq!(
snap_through_load_n(Fix128::ZERO, a, h, l),
Err(SnapThroughError::NonPositiveStiffness)
);
assert_eq!(
snap_through_load_n(e, neg, h, l),
Err(SnapThroughError::NonPositiveStiffness)
);
assert_eq!(
snap_through_load_n(e, a, neg, l),
Err(SnapThroughError::NegativeRise)
);
assert_eq!(
snap_through_load_n(e, a, h, Fix128::ZERO),
Err(SnapThroughError::NonPositiveSpan)
);
assert_eq!(
snap_through_load_n(e, a, h, neg),
Err(SnapThroughError::NonPositiveSpan)
);
}
#[test]
fn snap_through_shallow_arch() {
let p = snap_through_load_n(
Fix128::from_int(3500),
Fix128::from_int(10),
Fix128::from_int(2),
Fix128::from_int(20),
)
.unwrap();
let want = 3.079_201_435_678_003_8_f64 * 3500.0 * 10.0 * 0.001;
assert!(
(p.to_f64() - want).abs() < 1e-9 * want,
"got {} N, closed form {want} N",
p.to_f64()
);
assert!(
(p.to_f64() - 1302.0).abs() > 1000.0,
"the square law came back"
);
}
fn exact_truss_load(e: f64, area: f64, h: f64, span: f64, y: f64) -> f64 {
let a = span / 2.0;
let l0 = crate::det_math::sqrt64(a * a + h * h);
let ly = crate::det_math::sqrt64(a * a + y * y);
2.0 * e * area * (l0 / ly - 1.0) * (y / ly)
}
#[test]
fn snap_through_is_the_shallow_limit_of_the_exact_truss() {
let (e, area, span) = (3500.0_f64, 10.0_f64, 20.0_f64);
let mut previous_gap = f64::INFINITY;
for ratio in [0.1_f64, 0.05, 0.02] {
let h = ratio * span;
let steps = 2000;
let mut best = (0.0_f64, 0.0_f64);
for i in 1..steps {
let y = h * f64::from(i) / f64::from(steps);
let p = exact_truss_load(e, area, h, span, y);
if p > best.0 {
best = (p, y);
}
}
let shallow = snap_through_load_n(
Fix128::from_f64(e),
Fix128::from_f64(area),
Fix128::from_f64(h),
Fix128::from_f64(span),
)
.unwrap()
.to_f64();
let gap = (shallow / best.0 - 1.0).abs();
assert!(gap <= 8.0 * ratio * ratio, "h/L = {ratio}: ratio gap {gap}");
assert!(
gap < previous_gap,
"h/L = {ratio}: the gap must shrink with the rise"
);
previous_gap = gap;
let y_star = h / crate::det_math::sqrt64(3.0);
let shift = 2.0 * (2.0 * ratio) * (2.0 * ratio) * h + 2.0 * h / f64::from(steps);
assert!(
(best.1 - y_star).abs() <= shift,
"h/L = {ratio}: peak at {} vs h/√3 = {y_star}",
best.1
);
}
}
fn rel_f64(got: Fix128, want: f64, tol: f64) {
let g = got.to_f64();
assert!(
(g - want).abs() <= tol * want.abs(),
"got {g}, closed form {want}"
);
}
#[test]
fn weak_axis_of_box_tube_and_i_beam_matches_closed_form() {
let hr = CrossSection::HollowRectangular {
outer_width_mm: Fix128::from_int(20),
outer_height_mm: Fix128::from_int(30),
wall_mm: Fix128::from_int(2),
};
let i = (30.0 * 8000.0 - 26.0 * 4096.0) / 12.0;
let i_box = i;
rel_f64(weak_axis_second_moment_mm4(&hr), i, 1e-12);
let a = 20.0 * 30.0 - 16.0 * 26.0;
rel_f64(radius_of_gyration_mm(&hr), (i / a).sqrt(), 1e-12);
let ib = CrossSection::IBeam {
flange_width_mm: Fix128::from_int(50),
height_mm: Fix128::from_int(100),
flange_thickness_mm: Fix128::from_int(6),
web_thickness_mm: Fix128::from_int(4),
};
let i = (2.0 * 6.0 * 125_000.0 + 88.0 * 64.0) / 12.0;
let strong = (50.0 * 1_000_000.0 - 46.0 * 681_472.0) / 12.0;
assert!(i < strong);
rel_f64(weak_axis_second_moment_mm4(&ib), i, 1e-12);
let a = 2.0 * 50.0 * 6.0 + 4.0 * 88.0;
rel_f64(radius_of_gyration_mm(&ib), (i / a).sqrt(), 1e-12);
let turned = CrossSection::HollowRectangular {
outer_width_mm: Fix128::from_int(30),
outer_height_mm: Fix128::from_int(20),
wall_mm: Fix128::from_int(2),
};
rel_f64(weak_axis_second_moment_mm4(&turned), i_box, 1e-12);
}
#[test]
fn zero_area_section_has_no_radius_and_no_slenderness() {
let flat = CrossSection::Rectangular {
width_mm: Fix128::ZERO,
height_mm: Fix128::from_int(5),
};
assert_eq!(radius_of_gyration_mm(&flat), Fix128::ZERO);
assert_eq!(
slenderness_ratio(&flat, Fix128::from_int(100), ColumnEndCondition::PinPin),
Fix128::ZERO
);
}
#[test]
fn zero_yield_and_zero_slenderness_limits() {
assert_eq!(
transition_slenderness(Fix128::from_int(3500), Fix128::ZERO),
Fix128::ZERO
);
assert_eq!(
critical_stress_mpa(
Fix128::ZERO,
Fix128::from_int(200_000),
Fix128::from_int(250)
),
(Fix128::from_int(250), BucklingRegime::Yielding)
);
}
}