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