1use crate::beam_stress::{ColumnEndCondition, CrossSection};
47use crate::filament_db::MaterialProperties;
48use crate::math::Fix128;
49
50#[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#[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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum BucklingRegime {
149 Johnson,
151 Euler,
153 Yielding,
155}
156
157#[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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct ColumnBucklingReport {
203 pub radius_of_gyration_mm: Fix128,
205 pub slenderness: Fix128,
207 pub transition_slenderness: Fix128,
209 pub critical_stress_mpa: Fix128,
211 pub critical_load_n: Fix128,
213 pub regime: BucklingRegime,
215}
216
217#[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#[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
277pub(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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338#[non_exhaustive]
339pub enum SnapThroughError {
340 NonPositiveSpan,
342 NonPositiveStiffness,
344 NegativeRise,
346}
347
348#[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 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 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 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 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 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 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 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 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 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 let (s_below, r_below) = critical_stress_mpa(lambda_t - Fix128::from_ratio(1, 10), e, sy);
478 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 let diff = if s_below > s_above {
484 s_below - s_above
485 } else {
486 s_above - s_below
487 };
488 assert!(diff < Fix128::from_int(2), "diff = {}", diff.to_f32());
490 }
491
492 #[test]
493 fn analyze_column_full_report_pla() {
494 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 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 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}