Skip to main content

alice_physics/
buckling.rs

1//! Column and Shell Buckling Analysis
2//!
3//! Extends the basic
4//! Euler buckling formula already provided in `beam_stress.rs` with the
5//! **slenderness-ratio family** of column-stability criteria:
6//!
7//! - Radius of gyration `r = √(I / A)`.
8//! - Slenderness ratio `λ = K·L / r`.
9//! - **Euler regime** (`λ > λ_transition`): `σ_cr = π²·E / λ²`.
10//! - **Johnson parabolic regime** (`λ < λ_transition`): captures short
11//!   ("stocky") columns where Euler massively over-predicts.
12//! - **Transition slenderness** `λ_t = π · √(2E / σ_y)`.
13//! - **Local plate buckling** for thin-walled hollow sections (Timoshenko).
14//! - **Snap-through** critical load for shallow arches / clip-in features.
15//!
16//! # Why these matter for 3D printing
17//!
18//! SKADIS pegs, snap-fit tabs, and any thin extrusion behave as slender
19//! columns under bench load. Pure Euler over-predicts the safe load by 10-100×
20//! when the column is stocky; the Johnson correction gives realistic values.
21//! Local plate buckling matters for hollow-square supports where the walls
22//! bulge before the whole column bends.
23//!
24//! # References
25//!
26//! - Timoshenko & Gere, *Theory of Elastic Stability* 2nd ed. Chapters 1
27//!   (columns), 8 (plate buckling), 11 (shells).
28//! - Johnson, "Column and Strut Formulas", ASCE Trans. 42 (1899) —
29//!   parabolic formula for short columns.
30//! - Bažant & Cedolin, *Stability of Structures* (snap-through).
31//!
32//! # Integration status
33//!
34//! `analyze_column`, `ColumnBucklingReport`, and `BucklingRegime` are
35//! wired into `structural_solver.rs`. The radius-of-gyration / slenderness
36//! helpers are crate-internal and reached through `analyze_column`.
37//! `plate_buckling_mpa` (local wall buckling of a hollow section) and
38//! `snap_through_load_n` (shallow arch / clip) are reached through
39//! `StructuralSolver::plate_buckling_mpa` / `StructuralSolver::snap_through_load_n`,
40//! which take Young's modulus from the solver's material (and, for
41//! snap-through, the bar area from its cross-section). The life loop
42//! (`StructuralSolver::step`) does not trip on either: the plate width,
43//! edge factor and Poisson ratio are caller inputs that neither
44//! `CrossSection` nor `MaterialProperties` carries.
45
46use crate::beam_stress::{ColumnEndCondition, CrossSection};
47use crate::filament_db::MaterialProperties;
48use crate::math::Fix128;
49
50// ============================================================================
51// Radius of gyration & slenderness
52// ============================================================================
53
54/// Radius of gyration `r = √(I_weak / A)` (mm), about the weak axis
55/// ([`weak_axis_second_moment_mm4`]).
56///
57/// Governs how far the cross-section extends from the neutral axis on
58/// average — the natural "size" for buckling calculations.
59#[must_use]
60pub(crate) fn radius_of_gyration_mm(section: &CrossSection) -> Fix128 {
61    let a = section.area_mm2();
62    let i = weak_axis_second_moment_mm4(section);
63    if a.is_zero() {
64        return Fix128::ZERO;
65    }
66    (i / a).sqrt()
67}
68
69/// Second moment of area about the weak axis, `min(I_x, I_y)` (mm⁴): a column
70/// buckles about the axis of least stiffness, so the same bar gives the same
71/// answer however its width and height are labelled.
72/// `CrossSection::second_moment_of_area_mm4` is about the horizontal axis
73/// (the strong axis for a section taller than wide); `I_y` swaps the roles of
74/// width and height, and for the I-beam is the two flanges `2·t_f·b³/12`
75/// plus the web `(h − 2·t_f)·t_w³/12`.
76#[must_use]
77pub(crate) fn weak_axis_second_moment_mm4(section: &CrossSection) -> Fix128 {
78    let twelve = Fix128::from_int(12);
79    let cube = |v: Fix128| v * v * v;
80    let i_x = section.second_moment_of_area_mm4();
81    let i_y = match *section {
82        CrossSection::Rectangular {
83            width_mm,
84            height_mm,
85        } => height_mm * cube(width_mm) / twelve,
86        CrossSection::Circular { .. } | CrossSection::HollowCircular { .. } => i_x,
87        CrossSection::HollowRectangular {
88            outer_width_mm,
89            outer_height_mm,
90            wall_mm,
91        } => {
92            let inner_w = outer_width_mm - wall_mm.double();
93            let inner_h = outer_height_mm - wall_mm.double();
94            (outer_height_mm * cube(outer_width_mm) - inner_h * cube(inner_w)) / twelve
95        }
96        CrossSection::IBeam {
97            flange_width_mm,
98            height_mm,
99            flange_thickness_mm,
100            web_thickness_mm,
101        } => {
102            let web_h = height_mm - flange_thickness_mm.double();
103            (flange_thickness_mm.double() * cube(flange_width_mm) + web_h * cube(web_thickness_mm))
104                / twelve
105        }
106    };
107    i_x.min(i_y)
108}
109
110/// Slenderness ratio `λ = K·L / r`. Dimensionless.
111///
112/// - `λ < 50`: very stocky, buckling not a factor.
113/// - `50 < λ < 100`: intermediate — use Johnson formula.
114/// - `λ > 100`: slender — use Euler formula.
115#[must_use]
116pub(crate) fn slenderness_ratio(
117    section: &CrossSection,
118    length_mm: Fix128,
119    end_condition: ColumnEndCondition,
120) -> Fix128 {
121    let r = radius_of_gyration_mm(section);
122    if r.is_zero() {
123        return Fix128::ZERO;
124    }
125    end_condition.k_factor() * length_mm / r
126}
127
128/// Transition slenderness `λ_t = π · √(2·E / σ_y)`.
129///
130/// Below this the Johnson parabolic formula applies; above, Euler.
131/// The junction is such that both formulas give the same σ_cr = σ_y / 2.
132#[must_use]
133pub(crate) fn transition_slenderness(e_mpa: Fix128, sigma_y_mpa: Fix128) -> Fix128 {
134    if sigma_y_mpa.is_zero() {
135        return Fix128::ZERO;
136    }
137    let two_e = e_mpa.double();
138    let ratio = two_e / sigma_y_mpa;
139    Fix128::PI * ratio.sqrt()
140}
141
142// ============================================================================
143// Critical stress (Euler + Johnson)
144// ============================================================================
145
146/// Regime selected by the slenderness ratio.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum BucklingRegime {
149    /// Short / stocky — Johnson parabolic formula applies.
150    Johnson,
151    /// Slender — Euler formula applies.
152    Euler,
153    /// Below the compressive yield — no buckling; pure yielding governs.
154    Yielding,
155}
156
157/// Critical column-buckling stress (MPa) using the appropriate regime.
158///
159/// - Euler:  `σ_cr = π²·E / λ²`
160/// - Johnson: `σ_cr = σ_y · (1 − (σ_y / (4·π²·E)) · λ²)`
161///
162/// Zero slenderness (a zero-length column) is pure yielding at `σ_y`, the
163/// `λ → 0⁺` limit of the Johnson formula.
164#[must_use]
165pub(crate) fn critical_stress_mpa(
166    slenderness: Fix128,
167    e_mpa: Fix128,
168    sigma_y_mpa: Fix128,
169) -> (Fix128, BucklingRegime) {
170    if slenderness.is_zero() {
171        return (sigma_y_mpa, BucklingRegime::Yielding);
172    }
173    let lambda_t = transition_slenderness(e_mpa, sigma_y_mpa);
174    if slenderness >= lambda_t {
175        // Euler regime
176        let l2 = slenderness * slenderness;
177        let sigma = Fix128::PI * Fix128::PI * e_mpa / l2;
178        (sigma, BucklingRegime::Euler)
179    } else if !sigma_y_mpa.is_zero() {
180        // Johnson parabolic regime: σ_cr = σ_y · (1 − (σ_y·λ²) / (4·π²·E))
181        //                          = σ_y − (σ_y² · λ²) / (4·π²·E)
182        let l2 = slenderness * slenderness;
183        let pi2_e_4 = Fix128::PI * Fix128::PI * e_mpa * Fix128::from_int(4);
184        if pi2_e_4.is_zero() {
185            return (sigma_y_mpa, BucklingRegime::Yielding);
186        }
187        let reduction = sigma_y_mpa * sigma_y_mpa * l2 / pi2_e_4;
188        let sigma = if reduction < sigma_y_mpa {
189            sigma_y_mpa - reduction
190        } else {
191            Fix128::ZERO
192        };
193        (sigma, BucklingRegime::Johnson)
194    } else {
195        (Fix128::ZERO, BucklingRegime::Yielding)
196    }
197}
198
199/// Full column buckling analysis: geometry + material + length → critical
200/// load and stress with the appropriate regime.
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct ColumnBucklingReport {
203    /// Radius of gyration (mm).
204    pub radius_of_gyration_mm: Fix128,
205    /// Slenderness ratio λ = KL/r.
206    pub slenderness: Fix128,
207    /// Transition slenderness λ_t.
208    pub transition_slenderness: Fix128,
209    /// Critical stress σ_cr (MPa).
210    pub critical_stress_mpa: Fix128,
211    /// Critical load `P_cr = σ_cr · A` (N).
212    pub critical_load_n: Fix128,
213    /// Regime used.
214    pub regime: BucklingRegime,
215}
216
217/// Full analysis given cross-section, length, end condition and material.
218#[must_use]
219pub fn analyze_column(
220    section: &CrossSection,
221    length_mm: Fix128,
222    end_condition: ColumnEndCondition,
223    material: &MaterialProperties,
224) -> ColumnBucklingReport {
225    let e_mpa = material.youngs_modulus_gpa * Fix128::from_int(1000);
226    let sigma_y_mpa = material.yield_strength_mpa;
227    let r = radius_of_gyration_mm(section);
228    let lambda = slenderness_ratio(section, length_mm, end_condition);
229    let lambda_t = transition_slenderness(e_mpa, sigma_y_mpa);
230    let (sigma_cr, regime) = critical_stress_mpa(lambda, e_mpa, sigma_y_mpa);
231    let p_cr = sigma_cr * section.area_mm2();
232    ColumnBucklingReport {
233        radius_of_gyration_mm: r,
234        slenderness: lambda,
235        transition_slenderness: lambda_t,
236        critical_stress_mpa: sigma_cr,
237        critical_load_n: p_cr,
238        regime,
239    }
240}
241
242// ============================================================================
243// Local plate buckling
244// ============================================================================
245
246/// Critical plate buckling stress for a thin flange in a hollow section
247/// (Timoshenko eq. 8.8): `σ_cr = k · π²·E / (12·(1−ν²)) · (t / b)²`
248///
249/// - `b`: plate width (short dimension).
250/// - `t`: plate thickness.
251/// - `k`: geometric factor (simply supported all edges = 4, one edge free = 0.425).
252///
253/// Returns 0 when `width_mm` is zero or when `poisson` is ±1 (the
254/// `1 − ν²` denominator vanishes); both are degenerate inputs, not plates.
255#[must_use]
256pub(crate) fn plate_buckling_mpa(
257    e_mpa: Fix128,
258    poisson: Fix128,
259    thickness_mm: Fix128,
260    width_mm: Fix128,
261    k: Fix128,
262) -> Fix128 {
263    if width_mm.is_zero() {
264        return Fix128::ZERO;
265    }
266    let nu2 = poisson * poisson;
267    let one_minus_nu2 = Fix128::ONE - nu2;
268    if one_minus_nu2.is_zero() {
269        return Fix128::ZERO;
270    }
271    let ratio = thickness_mm / width_mm;
272    let ratio2 = ratio * ratio;
273    let numer = k * Fix128::PI * Fix128::PI * e_mpa * ratio2;
274    numer / (Fix128::from_int(12) * one_minus_nu2)
275}
276
277// ============================================================================
278// Snap-through buckling
279// ============================================================================
280
281/// Limit (snap-through) load of a shallow two-bar truss or clip feature of
282/// rise `h`, span `L`, cross-section area `A` and Young's modulus `E`.
283///
284/// The two bars of length `L₀ = √(a² + h²)`, `a = L/2`, carry the apex at
285/// height `y`; with the shallow strain `ε ≈ (y² − h²)/(2a²)` and the bar
286/// force `N = E A ε` the vertical balance at the apex is
287/// `P(y) = E A (h² − y²) y / a³`, stationary at `y = h/√3`, so
288///
289/// `P_max = (2 / (3√3)) · E A · (h/a)³ = (16 / (3√3)) · E A · (h/L)³ ≈ 3.079 · E A · (h/L)³`
290///
291/// (the classical von Mises truss, Bažant & Cedolin, two-bar truss). The cube
292/// is structural: it comes from the geometric nonlinearity of the strain.
293/// ⚠️ Before 2026-10-03 this function returned `3.72 · E A · (h/L)²`, a form
294/// whose source could not be reconstructed (a square law with a constant
295/// coefficient arises for arches with bending stiffness, not for an `E A`
296/// truss); the unit test of that version pinned the wrong law as a golden
297// LIMITATION(COV-STRUCT-044): The shallow approximation is good to `O((h/a)²)`
298/// value. The shallow approximation is good to `O((h/a)²)`; the exact
299/// `P(y) = 2 E A (L₀/L(y) − 1) · y/L(y)` is what the unit tests compare
300/// against.
301///
302/// A flat truss (`rise_mm == 0`) has no snap-through and returns `Ok(0)`.
303///
304/// # Errors
305///
306/// [`SnapThroughError::NonPositiveSpan`] for `span_mm ≤ 0` (the ratio
307/// `h/L` is undefined; before 2026-10-03 a zero span returned a silent
308/// `0 N`, which a caller cannot tell from a flat truss),
309/// [`SnapThroughError::NonPositiveStiffness`] for `e_mpa ≤ 0` or
310/// `section_area_mm2 ≤ 0` (no bar to buckle),
311/// [`SnapThroughError::NegativeRise`] for `rise_mm < 0` (the apex would be
312/// below the supports, so the load is a pull-through, not this formula).
313pub(crate) fn snap_through_load_n(
314    e_mpa: Fix128,
315    section_area_mm2: Fix128,
316    rise_mm: Fix128,
317    span_mm: Fix128,
318) -> Result<Fix128, SnapThroughError> {
319    if span_mm <= Fix128::ZERO {
320        return Err(SnapThroughError::NonPositiveSpan);
321    }
322    if e_mpa <= Fix128::ZERO || section_area_mm2 <= Fix128::ZERO {
323        return Err(SnapThroughError::NonPositiveStiffness);
324    }
325    if rise_mm < Fix128::ZERO {
326        return Err(SnapThroughError::NegativeRise);
327    }
328    // (16 / (3√3)) · E · A · (h/L)³
329    let ratio = rise_mm / span_mm;
330    let ratio3 = ratio * ratio * ratio;
331    let coefficient = Fix128::from_int(16) / (Fix128::from_int(3) * Fix128::from_int(3).sqrt());
332    Ok(coefficient * e_mpa * section_area_mm2 * ratio3)
333}
334
335/// Why the snap-through load (`StructuralSolver::snap_through_load_n`)
336/// could not be computed.
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338#[non_exhaustive]
339pub enum SnapThroughError {
340    /// `span_mm ≤ 0`: the rise ratio `h/L` is undefined.
341    NonPositiveSpan,
342    /// `e_mpa ≤ 0` or `section_area_mm2 ≤ 0`: there is no bar.
343    NonPositiveStiffness,
344    /// `rise_mm < 0`: the apex is below the supports.
345    NegativeRise,
346}
347
348// ============================================================================
349// Tests
350// ============================================================================
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn approx_eq(a: Fix128, b: Fix128, tol: Fix128) -> bool {
357        let d = if a > b { a - b } else { b - a };
358        d <= tol
359    }
360
361    #[test]
362    fn radius_of_gyration_rectangle() {
363        // 10x20 rectangle about the weak axis: I = 20·10³/12 = 1666.67,
364        // A = 200, r = √8.333 ≈ 2.887
365        let s = CrossSection::Rectangular {
366            width_mm: Fix128::from_int(10),
367            height_mm: Fix128::from_int(20),
368        };
369        let r = radius_of_gyration_mm(&s);
370        let expected = Fix128::from_ratio(2887, 1000);
371        assert!(
372            approx_eq(r, expected, Fix128::from_ratio(1, 100)),
373            "got {}, expected ~2.887",
374            r.to_f32()
375        );
376    }
377
378    #[test]
379    fn radius_of_gyration_circle_is_d_over_4() {
380        // For solid circle: r = √(I/A) = √(π·d⁴/64 / (π·d²/4)) = d/4
381        let s = CrossSection::Circular {
382            diameter_mm: Fix128::from_int(8),
383        };
384        let r = radius_of_gyration_mm(&s);
385        assert!(approx_eq(
386            r,
387            Fix128::from_int(2),
388            Fix128::from_ratio(1, 100)
389        ));
390    }
391
392    #[test]
393    fn slenderness_pin_pin_baseline() {
394        let s = CrossSection::Rectangular {
395            width_mm: Fix128::from_int(10),
396            height_mm: Fix128::from_int(20),
397        };
398        // L = 500mm, r ≈ 2.887 (weak axis) → λ ≈ 173.2
399        let lambda = slenderness_ratio(&s, Fix128::from_int(500), ColumnEndCondition::PinPin);
400        assert!(
401            approx_eq(lambda, Fix128::from_ratio(1732, 10), Fix128::from_int(1)),
402            "got {}, expected ~173.2",
403            lambda.to_f32()
404        );
405    }
406
407    #[test]
408    fn slenderness_cantilever_double_pinpin() {
409        let s = CrossSection::Circular {
410            diameter_mm: Fix128::from_int(10),
411        };
412        let l = Fix128::from_int(300);
413        let lambda_pin = slenderness_ratio(&s, l, ColumnEndCondition::PinPin);
414        let lambda_cant = slenderness_ratio(&s, l, ColumnEndCondition::Cantilever);
415        // K=2 → cantilever slenderness = 2× PinPin
416        let ratio = lambda_cant / lambda_pin;
417        assert!(approx_eq(
418            ratio,
419            Fix128::from_int(2),
420            Fix128::from_ratio(1, 100)
421        ));
422    }
423
424    #[test]
425    fn transition_slenderness_pla() {
426        // PLA: E=3500, σ_y=50 → λ_t = π√(7000/50) = π√140 ≈ 37.19
427        let lambda_t = transition_slenderness(Fix128::from_int(3500), Fix128::from_int(50));
428        let expected = Fix128::from_ratio(3719, 100);
429        assert!(
430            approx_eq(lambda_t, expected, Fix128::ONE),
431            "got {}, expected ~37.2",
432            lambda_t.to_f32()
433        );
434    }
435
436    #[test]
437    fn euler_regime_slender_column() {
438        // Very slender: λ = 200, well above transition
439        let e = Fix128::from_int(3500);
440        let sy = Fix128::from_int(50);
441        let (sigma, regime) = critical_stress_mpa(Fix128::from_int(200), e, sy);
442        assert_eq!(regime, BucklingRegime::Euler);
443        // σ_cr = π²·E/λ² = 9.87·3500/40000 ≈ 0.863 MPa
444        assert!(
445            approx_eq(
446                sigma,
447                Fix128::from_ratio(863, 1000),
448                Fix128::from_ratio(1, 10)
449            ),
450            "got {}",
451            sigma.to_f32()
452        );
453    }
454
455    #[test]
456    fn johnson_regime_stocky_column() {
457        // λ = 20 (very stocky) with PLA transition ~37
458        let e = Fix128::from_int(3500);
459        let sy = Fix128::from_int(50);
460        let (sigma, regime) = critical_stress_mpa(Fix128::from_int(20), e, sy);
461        assert_eq!(regime, BucklingRegime::Johnson);
462        // Johnson: σ_cr = 50 · (1 - 50·400/(4π²·3500)) = 50·(1 - 20000/138175)
463        //        = 50·(1 - 0.1447) = 50·0.855 = 42.7 MPa
464        assert!(
465            approx_eq(sigma, Fix128::from_ratio(427, 10), Fix128::ONE),
466            "got {}, expected ~42.7",
467            sigma.to_f32()
468        );
469    }
470
471    #[test]
472    fn at_transition_regimes_agree() {
473        let e = Fix128::from_int(3500);
474        let sy = Fix128::from_int(50);
475        let lambda_t = transition_slenderness(e, sy);
476        // Just below λ_t (Johnson)
477        let (s_below, r_below) = critical_stress_mpa(lambda_t - Fix128::from_ratio(1, 10), e, sy);
478        // Just above λ_t (Euler)
479        let (s_above, r_above) = critical_stress_mpa(lambda_t + Fix128::from_ratio(1, 10), e, sy);
480        assert_eq!(r_below, BucklingRegime::Johnson);
481        assert_eq!(r_above, BucklingRegime::Euler);
482        // Both should give ≈ σ_y / 2 = 25 MPa
483        let diff = if s_below > s_above {
484            s_below - s_above
485        } else {
486            s_above - s_below
487        };
488        // Small mismatch due to derivative discontinuity at transition
489        assert!(diff < Fix128::from_int(2), "diff = {}", diff.to_f32());
490    }
491
492    #[test]
493    fn analyze_column_full_report_pla() {
494        // 10x10 PLA column 500mm long, pin-pin
495        let s = CrossSection::Rectangular {
496            width_mm: Fix128::from_int(10),
497            height_mm: Fix128::from_int(10),
498        };
499        let report = analyze_column(
500            &s,
501            Fix128::from_int(500),
502            ColumnEndCondition::PinPin,
503            &MaterialProperties::pla(),
504        );
505        // λ = 500 / r where r ≈ 2.89 → λ ≈ 173 → Euler regime
506        assert!(report.slenderness > Fix128::from_int(150));
507        assert_eq!(report.regime, BucklingRegime::Euler);
508        assert!(report.critical_load_n > Fix128::ZERO);
509    }
510
511    #[test]
512    fn analyze_column_short_pla_is_johnson() {
513        // Very short PLA column: 20mm, 10x10 cross-section
514        let s = CrossSection::Rectangular {
515            width_mm: Fix128::from_int(10),
516            height_mm: Fix128::from_int(10),
517        };
518        let report = analyze_column(
519            &s,
520            Fix128::from_int(20),
521            ColumnEndCondition::PinPin,
522            &MaterialProperties::pla(),
523        );
524        // λ ≈ 20/2.89 ≈ 6.9 → well below transition → Johnson
525        assert_eq!(report.regime, BucklingRegime::Johnson);
526    }
527
528    #[test]
529    fn plate_buckling_scales_with_thickness_squared() {
530        let e = Fix128::from_int(3500);
531        let nu = Fix128::from_ratio(35, 100);
532        let s1 = plate_buckling_mpa(
533            e,
534            nu,
535            Fix128::from_int(1),
536            Fix128::from_int(20),
537            Fix128::from_int(4),
538        );
539        let s2 = plate_buckling_mpa(
540            e,
541            nu,
542            Fix128::from_int(2),
543            Fix128::from_int(20),
544            Fix128::from_int(4),
545        );
546        // Doubling t → 4× σ_cr
547        let ratio = s2 / s1;
548        assert!(approx_eq(
549            ratio,
550            Fix128::from_int(4),
551            Fix128::from_ratio(1, 100)
552        ));
553    }
554
555    #[test]
556    fn plate_buckling_scales_with_width_inverse_squared() {
557        let e = Fix128::from_int(3500);
558        let nu = Fix128::from_ratio(35, 100);
559        let s_narrow = plate_buckling_mpa(
560            e,
561            nu,
562            Fix128::from_int(1),
563            Fix128::from_int(10),
564            Fix128::from_int(4),
565        );
566        let s_wide = plate_buckling_mpa(
567            e,
568            nu,
569            Fix128::from_int(1),
570            Fix128::from_int(20),
571            Fix128::from_int(4),
572        );
573        // Doubling width → σ_cr / 4
574        let ratio = s_narrow / s_wide;
575        assert!(approx_eq(
576            ratio,
577            Fix128::from_int(4),
578            Fix128::from_ratio(1, 100)
579        ));
580    }
581
582    /// Oracle: Timoshenko & Gere eq. 8.8 evaluated by hand in f64,
583    /// `σ_cr = k·π²·E / (12·(1 − ν²)) · (t/b)²` with `k = 4`, `E = 3500 MPa`,
584    /// `ν = 0.35`, `t = 1 mm`, `b = 20 mm`:
585    /// `4·π²·3500 / (12·0.8775) · 0.0025 = 32.8050 MPa`.
586    #[test]
587    fn plate_buckling_matches_timoshenko_closed_form() {
588        let got = plate_buckling_mpa(
589            Fix128::from_int(3500),
590            Fix128::from_ratio(35, 100),
591            Fix128::from_int(1),
592            Fix128::from_int(20),
593            Fix128::from_int(4),
594        )
595        .to_f64();
596        let pi = core::f64::consts::PI;
597        let want =
598            4.0 * pi * pi * 3500.0 / (12.0 * (1.0 - 0.35 * 0.35)) * (1.0 / 20.0) * (1.0 / 20.0);
599        assert!(((got - want) / want).abs() < 1e-9, "got {got}, want {want}");
600    }
601
602    /// Degenerate inputs documented on `plate_buckling_mpa`: zero width and
603    /// `ν = ±1` return 0 (early return, not a division by zero); zero
604    /// thickness and zero modulus give 0 through the formula itself.
605    #[test]
606    fn plate_buckling_degenerate_inputs_return_zero() {
607        let e = Fix128::from_int(3500);
608        let nu = Fix128::from_ratio(35, 100);
609        let one = Fix128::ONE;
610        let k = Fix128::from_int(4);
611        assert_eq!(
612            plate_buckling_mpa(e, nu, one, Fix128::ZERO, k),
613            Fix128::ZERO
614        );
615        assert_eq!(
616            plate_buckling_mpa(e, one, one, Fix128::from_int(20), k),
617            Fix128::ZERO
618        );
619        assert_eq!(
620            plate_buckling_mpa(e, Fix128::NEG_ONE, one, Fix128::from_int(20), k),
621            Fix128::ZERO
622        );
623        assert_eq!(
624            plate_buckling_mpa(e, nu, Fix128::ZERO, Fix128::from_int(20), k),
625            Fix128::ZERO
626        );
627        assert_eq!(
628            plate_buckling_mpa(Fix128::ZERO, nu, one, Fix128::from_int(20), k),
629            Fix128::ZERO
630        );
631    }
632
633    /// Degenerate inputs for `snap_through_load_n`: a flat truss (zero rise)
634    /// is `Ok(0)` (no snap-through exists), zero or negative area / modulus
635    /// are `NonPositiveStiffness`, a negative rise is `NegativeRise`, and a
636    /// zero or negative span is `NonPositiveSpan` rather than the silent
637    /// `0 N` of the pre-2026-10-03 version (which `Fix128`'s zero division
638    /// would also have produced without any guard).
639    #[test]
640    fn snap_through_degenerate_inputs() {
641        let e = Fix128::from_int(3500);
642        let a = Fix128::from_int(10);
643        let h = Fix128::from_int(2);
644        let l = Fix128::from_int(20);
645        let neg = Fix128::from_int(-1);
646        assert_eq!(snap_through_load_n(e, a, Fix128::ZERO, l), Ok(Fix128::ZERO));
647        assert_eq!(
648            snap_through_load_n(e, Fix128::ZERO, h, l),
649            Err(SnapThroughError::NonPositiveStiffness)
650        );
651        assert_eq!(
652            snap_through_load_n(Fix128::ZERO, a, h, l),
653            Err(SnapThroughError::NonPositiveStiffness)
654        );
655        assert_eq!(
656            snap_through_load_n(e, neg, h, l),
657            Err(SnapThroughError::NonPositiveStiffness)
658        );
659        assert_eq!(
660            snap_through_load_n(e, a, neg, l),
661            Err(SnapThroughError::NegativeRise)
662        );
663        assert_eq!(
664            snap_through_load_n(e, a, h, Fix128::ZERO),
665            Err(SnapThroughError::NonPositiveSpan)
666        );
667        assert_eq!(
668            snap_through_load_n(e, a, h, neg),
669            Err(SnapThroughError::NonPositiveSpan)
670        );
671    }
672
673    /// Oracle: `P_max = (16/(3√3)) · E A · (h/L)³` by hand.
674    /// E = 3500 MPa, A = 10 mm², h = 2, L = 20 (h/L = 0.1):
675    /// `3.0792014356780038 · 3500 · 10 · 0.001 = 107.772050 N`.
676    /// The earlier golden (1302 N) pinned the square law this function used
677    /// to return; it is kept here as the value that must NOT come back.
678    #[test]
679    fn snap_through_shallow_arch() {
680        let p = snap_through_load_n(
681            Fix128::from_int(3500),
682            Fix128::from_int(10),
683            Fix128::from_int(2),
684            Fix128::from_int(20),
685        )
686        .unwrap();
687        let want = 3.079_201_435_678_003_8_f64 * 3500.0 * 10.0 * 0.001;
688        assert!(
689            (p.to_f64() - want).abs() < 1e-9 * want,
690            "got {} N, closed form {want} N",
691            p.to_f64()
692        );
693        assert!(
694            (p.to_f64() - 1302.0).abs() > 1000.0,
695            "the square law came back"
696        );
697    }
698
699    /// Exact two-bar (von Mises) truss load at apex height `y`, in the test's
700    /// own arithmetic: `P(y) = 2 E A (L₀/L(y) − 1) · y / L(y)` with
701    /// `L(y) = √(a² + y²)`, `L₀ = L(h)`, `a = L/2` (magnitude of the
702    /// compressive load).
703    fn exact_truss_load(e: f64, area: f64, h: f64, span: f64, y: f64) -> f64 {
704        let a = span / 2.0;
705        let l0 = crate::det_math::sqrt64(a * a + h * h);
706        let ly = crate::det_math::sqrt64(a * a + y * y);
707        2.0 * e * area * (l0 / ly - 1.0) * (y / ly)
708    }
709
710    /// Oracle: the shallow formula is the `h/L → 0` limit of the exact truss.
711    /// The exact limit load is found by scanning `y ∈ (0, h)`; the shallow
712    /// approximation differs by `O((h/a)²)`, so the ratio must sit within
713    /// `1 ± 8 (h/L)²` and approach 1 as `h/L` shrinks (three rises). The
714    /// stationary point of the shallow law, `y = h/√3`, is where the exact
715    /// scan peaks up to the same order of shift.
716    #[test]
717    fn snap_through_is_the_shallow_limit_of_the_exact_truss() {
718        let (e, area, span) = (3500.0_f64, 10.0_f64, 20.0_f64);
719        let mut previous_gap = f64::INFINITY;
720        for ratio in [0.1_f64, 0.05, 0.02] {
721            let h = ratio * span;
722            let steps = 2000;
723            let mut best = (0.0_f64, 0.0_f64);
724            for i in 1..steps {
725                let y = h * f64::from(i) / f64::from(steps);
726                let p = exact_truss_load(e, area, h, span, y);
727                if p > best.0 {
728                    best = (p, y);
729                }
730            }
731            let shallow = snap_through_load_n(
732                Fix128::from_f64(e),
733                Fix128::from_f64(area),
734                Fix128::from_f64(h),
735                Fix128::from_f64(span),
736            )
737            .unwrap()
738            .to_f64();
739            let gap = (shallow / best.0 - 1.0).abs();
740            assert!(gap <= 8.0 * ratio * ratio, "h/L = {ratio}: ratio gap {gap}");
741            assert!(
742                gap < previous_gap,
743                "h/L = {ratio}: the gap must shrink with the rise"
744            );
745            previous_gap = gap;
746            // The exact peak sits at `h/√3` up to the same `O((h/a)²)`
747            // relative shift (h/a = 2 h/L), plus one scan step.
748            let y_star = h / crate::det_math::sqrt64(3.0);
749            let shift = 2.0 * (2.0 * ratio) * (2.0 * ratio) * h + 2.0 * h / f64::from(steps);
750            assert!(
751                (best.1 - y_star).abs() <= shift,
752                "h/L = {ratio}: peak at {} vs h/√3 = {y_star}",
753                best.1
754            );
755        }
756    }
757
758    fn rel_f64(got: Fix128, want: f64, tol: f64) {
759        let g = got.to_f64();
760        assert!(
761            (g - want).abs() <= tol * want.abs(),
762            "got {g}, closed form {want}"
763        );
764    }
765
766    #[test]
767    fn weak_axis_of_box_tube_and_i_beam_matches_closed_form() {
768        // box tube 20 x 30, wall 2: weak axis is the 20 mm width,
769        // I_y = (H B^3 - h b^3) / 12
770        let hr = CrossSection::HollowRectangular {
771            outer_width_mm: Fix128::from_int(20),
772            outer_height_mm: Fix128::from_int(30),
773            wall_mm: Fix128::from_int(2),
774        };
775        let i = (30.0 * 8000.0 - 26.0 * 4096.0) / 12.0;
776        let i_box = i;
777        rel_f64(weak_axis_second_moment_mm4(&hr), i, 1e-12);
778        let a = 20.0 * 30.0 - 16.0 * 26.0;
779        rel_f64(radius_of_gyration_mm(&hr), (i / a).sqrt(), 1e-12);
780
781        // I-beam: two flanges 2 t_f b^3 / 12 plus the web (h - 2 t_f) t_w^3 / 12
782        let ib = CrossSection::IBeam {
783            flange_width_mm: Fix128::from_int(50),
784            height_mm: Fix128::from_int(100),
785            flange_thickness_mm: Fix128::from_int(6),
786            web_thickness_mm: Fix128::from_int(4),
787        };
788        let i = (2.0 * 6.0 * 125_000.0 + 88.0 * 64.0) / 12.0;
789        let strong = (50.0 * 1_000_000.0 - 46.0 * 681_472.0) / 12.0;
790        assert!(i < strong);
791        rel_f64(weak_axis_second_moment_mm4(&ib), i, 1e-12);
792        let a = 2.0 * 50.0 * 6.0 + 4.0 * 88.0;
793        rel_f64(radius_of_gyration_mm(&ib), (i / a).sqrt(), 1e-12);
794
795        // the same tube turned on its side gives the same weak-axis value
796        // (I_x and I_y round their quotient by 12 separately: within 1e-12)
797        let turned = CrossSection::HollowRectangular {
798            outer_width_mm: Fix128::from_int(30),
799            outer_height_mm: Fix128::from_int(20),
800            wall_mm: Fix128::from_int(2),
801        };
802        rel_f64(weak_axis_second_moment_mm4(&turned), i_box, 1e-12);
803    }
804
805    #[test]
806    fn zero_area_section_has_no_radius_and_no_slenderness() {
807        let flat = CrossSection::Rectangular {
808            width_mm: Fix128::ZERO,
809            height_mm: Fix128::from_int(5),
810        };
811        assert_eq!(radius_of_gyration_mm(&flat), Fix128::ZERO);
812        assert_eq!(
813            slenderness_ratio(&flat, Fix128::from_int(100), ColumnEndCondition::PinPin),
814            Fix128::ZERO
815        );
816    }
817
818    #[test]
819    fn zero_yield_and_zero_slenderness_limits() {
820        assert_eq!(
821            transition_slenderness(Fix128::from_int(3500), Fix128::ZERO),
822            Fix128::ZERO
823        );
824        // a zero-length column is pure yielding at sigma_y (lambda -> 0+ of Johnson)
825        assert_eq!(
826            critical_stress_mpa(
827                Fix128::ZERO,
828                Fix128::from_int(200_000),
829                Fix128::from_int(250)
830            ),
831            (Fix128::from_int(250), BucklingRegime::Yielding)
832        );
833    }
834}