Skip to main content

axiolid_profile/
parameterized.rs

1//! Compact parameterized profile families.
2
3use axiolid_core::Scalar;
4
5/// Rectangle, optionally rounded at the corners.
6#[derive(Debug, Clone, Copy, PartialEq)]
7#[cfg_attr(
8    feature = "serde",
9    derive(serde::Serialize, serde::Deserialize),
10    serde(deny_unknown_fields)
11)]
12pub struct RectangleProfile {
13    /// Extent along local x.
14    pub x: Scalar,
15    /// Extent along local y.
16    pub y: Scalar,
17    /// Optional wall thickness. `None` denotes a filled section.
18    pub thickness: Option<Scalar>,
19    /// Optional outer corner radius.
20    pub outer_radius: Option<Scalar>,
21    /// Optional inner corner radius for hollow sections.
22    pub inner_radius: Option<Scalar>,
23}
24
25/// Circle or annulus.
26#[derive(Debug, Clone, Copy, PartialEq)]
27#[cfg_attr(
28    feature = "serde",
29    derive(serde::Serialize, serde::Deserialize),
30    serde(deny_unknown_fields)
31)]
32pub struct CircleProfile {
33    /// Outer radius.
34    pub radius: Scalar,
35    /// Optional wall thickness. `None` denotes a filled disk.
36    pub thickness: Option<Scalar>,
37}
38
39/// Ellipse.
40#[derive(Debug, Clone, Copy, PartialEq)]
41#[cfg_attr(
42    feature = "serde",
43    derive(serde::Serialize, serde::Deserialize),
44    serde(deny_unknown_fields)
45)]
46pub struct EllipseProfile {
47    /// Semi-axis along local x.
48    pub semi_axis_x: Scalar,
49    /// Semi-axis along local y.
50    pub semi_axis_y: Scalar,
51}
52
53/// Generic structural section dimensions.
54///
55/// Each variant carries the dimensions the corresponding source entity
56/// declares, including the optional fillet radii, edge radii and flange
57/// slopes. Those optionals are not decoration: for a rolled steel section the
58/// root fillet is a large share of the cross-sectional area, so discarding it
59/// yields a section whose area, second moment and mass are all wrong while
60/// still looking like the right shape.
61///
62/// Angles are radians, lengths are model units. `None` means the source did
63/// not state the value, which is distinct from stating zero.
64#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq)]
66#[cfg_attr(
67    feature = "serde",
68    derive(serde::Serialize, serde::Deserialize),
69    serde(deny_unknown_fields)
70)]
71pub enum SectionProfile {
72    /// Symmetric I section: equal top and bottom flanges.
73    I {
74        depth: Scalar,
75        width: Scalar,
76        web_thickness: Scalar,
77        flange_thickness: Scalar,
78        /// Web-to-flange root fillet.
79        fillet_radius: Option<Scalar>,
80        /// Radius at the flange toe.
81        flange_edge_radius: Option<Scalar>,
82        /// Flange taper, radians from the horizontal.
83        flange_slope: Option<Scalar>,
84    },
85    /// I section with independent top and bottom flanges.
86    ///
87    /// Separate from `I` because collapsing the two flange widths into one
88    /// silently symmetrises the section: the shape stays plausible while the
89    /// neutral axis, and therefore every bending result, moves.
90    AsymmetricI {
91        depth: Scalar,
92        web_thickness: Scalar,
93        bottom_flange_width: Scalar,
94        bottom_flange_thickness: Scalar,
95        bottom_fillet_radius: Option<Scalar>,
96        bottom_flange_edge_radius: Option<Scalar>,
97        bottom_flange_slope: Option<Scalar>,
98        top_flange_width: Scalar,
99        /// Defaults to the bottom thickness when absent.
100        top_flange_thickness: Option<Scalar>,
101        top_fillet_radius: Option<Scalar>,
102        top_flange_edge_radius: Option<Scalar>,
103        top_flange_slope: Option<Scalar>,
104    },
105    /// L or angle section.
106    L {
107        depth: Scalar,
108        /// Defaults to the depth when absent, giving an equal angle.
109        width: Option<Scalar>,
110        thickness: Scalar,
111        fillet_radius: Option<Scalar>,
112        edge_radius: Option<Scalar>,
113        /// Leg taper, radians.
114        leg_slope: Option<Scalar>,
115    },
116    /// T section.
117    T {
118        depth: Scalar,
119        flange_width: Scalar,
120        web_thickness: Scalar,
121        flange_thickness: Scalar,
122        fillet_radius: Option<Scalar>,
123        flange_edge_radius: Option<Scalar>,
124        web_edge_radius: Option<Scalar>,
125        web_slope: Option<Scalar>,
126        flange_slope: Option<Scalar>,
127    },
128    /// U or channel section.
129    U {
130        depth: Scalar,
131        flange_width: Scalar,
132        web_thickness: Scalar,
133        flange_thickness: Scalar,
134        fillet_radius: Option<Scalar>,
135        edge_radius: Option<Scalar>,
136        flange_slope: Option<Scalar>,
137    },
138    /// C or lipped-channel section.
139    C {
140        depth: Scalar,
141        width: Scalar,
142        wall_thickness: Scalar,
143        /// Length of the returned lip.
144        girth: Scalar,
145        internal_fillet_radius: Option<Scalar>,
146    },
147    /// Z section.
148    Z {
149        depth: Scalar,
150        flange_width: Scalar,
151        web_thickness: Scalar,
152        flange_thickness: Scalar,
153        fillet_radius: Option<Scalar>,
154        edge_radius: Option<Scalar>,
155    },
156    /// Trapezium.
157    Trapezium {
158        bottom_x: Scalar,
159        top_x: Scalar,
160        y: Scalar,
161        /// Offset of the top edge relative to the bottom; may be negative.
162        top_offset: Scalar,
163    },
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    /// An asymmetric I is not representable as a symmetric one.
171    ///
172    /// This is the whole reason `AsymmetricI` is a separate variant. Before it
173    /// existed an adapter had to pick one of the two flange widths, producing
174    /// a section that looks right and whose neutral axis is in the wrong
175    /// place. Distinct variants make that substitution impossible to express.
176    #[test]
177    fn an_asymmetric_i_is_a_distinct_variant_from_a_symmetric_one() {
178        let symmetric = SectionProfile::I {
179            depth: 0.4,
180            width: 0.3,
181            web_thickness: 0.011,
182            flange_thickness: 0.019,
183            fillet_radius: Some(0.021),
184            flange_edge_radius: None,
185            flange_slope: None,
186        };
187        let asymmetric = SectionProfile::AsymmetricI {
188            depth: 0.4,
189            web_thickness: 0.011,
190            bottom_flange_width: 0.3,
191            bottom_flange_thickness: 0.019,
192            bottom_fillet_radius: Some(0.021),
193            bottom_flange_edge_radius: None,
194            bottom_flange_slope: None,
195            // The distinguishing dimension: a wider top flange.
196            top_flange_width: 0.2,
197            top_flange_thickness: Some(0.019),
198            top_fillet_radius: Some(0.021),
199            top_flange_edge_radius: None,
200            top_flange_slope: None,
201        };
202        assert_ne!(symmetric, asymmetric);
203    }
204
205    /// A stated fillet radius is not the same as an absent one.
206    ///
207    /// `None` means the source did not declare the value; `Some(0.0)` means it
208    /// declared a sharp corner. For a rolled section the root fillet carries
209    /// real area, so the two must not compare equal.
210    #[test]
211    fn an_absent_fillet_differs_from_a_declared_zero() {
212        let make = |fillet| SectionProfile::U {
213            depth: 0.3,
214            flange_width: 0.1,
215            web_thickness: 0.0075,
216            flange_thickness: 0.0125,
217            fillet_radius: fillet,
218            edge_radius: None,
219            flange_slope: None,
220        };
221        assert_ne!(make(None), make(Some(0.0)));
222        assert_eq!(make(Some(0.015)), make(Some(0.015)));
223    }
224
225    /// Every section variant survives a round trip through `Profile`.
226    #[test]
227    fn a_section_wraps_into_a_profile_without_loss() {
228        let section = SectionProfile::L {
229            depth: 0.1,
230            width: None,
231            thickness: 0.008,
232            fillet_radius: Some(0.012),
233            edge_radius: Some(0.006),
234            leg_slope: None,
235        };
236        let profile = crate::Profile::Section(section.clone());
237        match profile {
238            crate::Profile::Section(round_tripped) => assert_eq!(section, round_tripped),
239            other => panic!("expected a section, got {other:?}"),
240        }
241    }
242}