Skip to main content

hpr_design/
fins.rs

1//! Fin sets and tube fins: planforms, cross-sections, tabs, and mass properties from geometry.
2//!
3//! **Fin frame.** A fin set's frame has its origin on the body axis at the station of the root
4//! chord's leading edge. Fin `k` of `N` lies in the plane through the axis at roll angle
5//! `φ_k = φ_0 + 2πk/N` from `x_B` toward `y_B`. In a fin's own coordinates, `x` runs aft from the
6//! root leading edge, the span `h` runs outward from the body surface (radius `R_b + h`), and the
7//! thickness coordinate `τ` is normal to the fin plane. Planform definitions follow S. Niskanen,
8//! *OpenRocket technical documentation* v13.05, §3.2.2, pp. 25–29: a trapezoidal fin has root chord
9//! `c_r`, tip chord `c_t` parallel to the body, span `s`, and sweep `x_t`, the axial distance from
10//! the root leading edge to the tip leading edge; an elliptical fin has chord
11//! `c(h) = c_r √(1 − (h/s)²)` centered on the root chord; a freeform fin is a polygon.
12//!
13//! **Mass.** Each fin is a plate whose local thickness varies along each chord with the
14//! cross-section. At span `h`, a chord from `a` to `b` (length `c`) contributes the moments
15//! `M_k = ∫ x^k t(x) dx` and `T = ∫ t(x)³/12 dx`:
16//!
17//! ```text
18//! square:   t(x) = t
19//! rounded:  semicircular leading and trailing edges of radius a_r = min(t, c)/2
20//! airfoil:  t(x) = 10 t P(ξ),  P = 0.2969√ξ − 0.1260ξ − 0.3516ξ² + 0.2843ξ³ − 0.1015ξ⁴
21//! ```
22//!
23//! The airfoil is the NACA four-digit symmetric thickness distribution (I. H. Abbott and A. E.
24//! von Doenhoff, *Theory of Wing Sections*, Dover, 1959, eq. 6.2) with its maximum thickness,
25//! `1.0003 t` at `ξ = 0.2998`, scaled to the fin thickness. OpenRocket's documentation uses the
26//! cross-section only for drag (§3.4.4, pp. 49–50); hpr-design also counts the volume it removes.
27//! The fin's moments about the fin-set frame are then
28//!
29//! ```text
30//! m = ρ ∫ M_0 dh,  ∫ r dm = ρ ∫ (R_b + h) M_0 dh,  ∫ r² dm = ρ ∫ (R_b + h)² M_0 dh,
31//! ∫ x dm = ρ ∫ M_1 dh,  ∫ x² dm = ρ ∫ M_2 dh,  ∫ r x dm = ρ ∫ (R_b + h) M_1 dh,  ∫ τ² dm = ρ ∫ T dh
32//! ```
33//!
34//! and with the fin at `φ = 0` (points at `(r, τ, −x)` in body axes)
35//!
36//! ```text
37//! I_xx = ∫(τ² + x²) dm,  I_yy = ∫(r² + x²) dm,  I_zz = ∫(r² + τ²) dm,  I_xz = ∫ r x dm,  I_xy = I_yz = 0
38//! ```
39//!
40//! about the origin. A tab is a square-section slab below the root (`−h_tab ≤ h ≤ 0`). The flat
41//! root is taken to sit on the body at radius `R_b`; the sliver between a flat root and the curved
42//! tube, `t²/8R_b` deep, is ignored. On a nose cone or a transition the surface is not level
43//! along the root: `R_b` is the body's radius at the root leading edge, `h` is measured from it,
44//! and a freeform fin's root runs through its root points on the surface (OpenRocket's reading of
45//! a fin on a nose cone, [ADR-166][adr-166]). **Cant** `δ` turns each fin (with its tab) by `δ` about its own
46//! outward span axis through the root mid-chord, right-handed, so a positive cant turns fin 0's
47//! leading edge toward `−y_B`. See `docs/physics/mass.md`.
48//!
49//! **Fillets.** A fillet of radius `r` runs the root chord `c_r` on each face of each fin. Its
50//! section is the region between the fin's mid-plane, the body's circle of radius `R_b`, and a
51//! circle of radius `r` tangent to both: OpenRocket 24.12's reading, which leaves the fin's
52//! thickness out. Its mass and center of mass match OpenRocket's to 1e-15 on nine probe designs
53//! ([ADR-096][adr-096], the decision on fillets). With the fillet circle's center at
54//! `(c, r)`, `c = √(R_b² + 2 R_b r)` and `θ = atan(r / c)`, the section is the triangle
55//! `(0, 0), (c, 0), (c, r)` less the body's sector of angle `θ` and the fillet's of `π/2 − θ`:
56//!
57//! ```text
58//! A = c r / 2 − R_b² θ / 2 − r² (π/2 − θ) / 2
59//! ```
60//!
61//! which tends to `r² (1 − π/4)` on a flat body. Its moments come the same way, and the fillets
62//! are a prism of that section along the root, in the fillet's own material.
63//!
64//! [adr-166]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0166-a-fin-root-that-follows-the-body.md
65//! [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
66
67use std::f64::consts::PI;
68
69use hpr_core::quadrature::{Tolerance, integrate};
70use hpr_core::{DMat3, DQuat, DVec3};
71use serde::{Deserialize, Serialize};
72
73use crate::error::DesignError;
74use crate::mass::MassProperties;
75use crate::material::Material;
76use crate::shapes::{Profile, check_dimension};
77
78/// The outline of one fin.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
81#[non_exhaustive]
82pub enum FinPlanform {
83    /// A trapezoid with its tip chord parallel to the root.
84    Trapezoidal {
85        /// Root chord, m.
86        root_chord_m: f64,
87        /// Tip chord, m (zero for a pointed fin).
88        tip_chord_m: f64,
89        /// Span from the body surface to the tip, m.
90        span_m: f64,
91        /// Axial distance from the root leading edge aft to the tip leading edge, m.
92        sweep_m: f64,
93    },
94    /// Half an ellipse on the root chord.
95    Elliptical {
96        /// Root chord, m.
97        root_chord_m: f64,
98        /// Span, m.
99        span_m: f64,
100    },
101    /// A polygon given as `[x, h]` points from the root leading edge, which must be `[0, 0]`,
102    /// around to the root trailing edge `[c_r, h_r]` with `c_r > 0` and `h_r ≥ 0`, closed along
103    /// the root; `x` runs aft and `h` outward, in meters. On a body tube the root is level,
104    /// `h_r = 0`; on a nose cone or a transition it follows the surface, through `root_m`.
105    Freeform {
106        /// The outline, m.
107        points_m: Vec<[f64; 2]>,
108        /// The root's points between its trailing and leading edges, aft to fore, m: none for a
109        /// straight root. The root is straight between them.
110        #[serde(default, skip_serializing_if = "Vec::is_empty")]
111        root_m: Vec<[f64; 2]>,
112    },
113}
114
115/// The shape of a fin's section along its chord.
116#[derive(
117    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, schemars::JsonSchema,
118)]
119#[serde(rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum FinCrossSection {
122    /// Constant thickness, square edges.
123    #[default]
124    Square,
125    /// Semicircular leading and trailing edges.
126    Rounded,
127    /// The NACA four-digit symmetric thickness distribution.
128    Airfoil,
129}
130
131/// A rectangular tab below a fin's root, reaching into the body.
132#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
133#[serde(deny_unknown_fields)]
134pub struct FinTab {
135    /// Depth below the root, m.
136    pub height_m: f64,
137    /// Length along the root, m.
138    pub length_m: f64,
139    /// Distance from the root leading edge aft to the tab's leading edge, m.
140    pub offset_m: f64,
141}
142
143/// Fillets along each fin's root: a concave joint on both faces, running the root chord.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
145#[serde(deny_unknown_fields)]
146pub struct FinFillet {
147    /// Radius of the fillet's concave face, m.
148    pub radius_m: f64,
149    /// Material (bulk).
150    pub material: Material,
151}
152
153/// A set of identical fins spaced evenly around the body.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
155#[serde(deny_unknown_fields)]
156pub struct FinSet {
157    /// Number of fins, 1 to 64 (`FinSet::MAX_COUNT`).
158    pub count: u32,
159    /// Outline of each fin.
160    pub planform: FinPlanform,
161    /// Maximum thickness, m.
162    pub thickness_m: f64,
163    /// Section shape.
164    #[serde(default)]
165    pub cross_section: FinCrossSection,
166    /// Optional tab below each root.
167    #[serde(default)]
168    pub tab: Option<FinTab>,
169    /// Optional fillets along each root.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub fillet: Option<FinFillet>,
172    /// Cant angle, rad.
173    #[serde(default)]
174    pub cant_rad: f64,
175    /// Roll angle of the first fin from `x_B` toward `y_B`, rad.
176    #[serde(default)]
177    pub base_angle_rad: f64,
178    /// Material (bulk).
179    pub material: Material,
180}
181
182/// Area and centroid of a planform.
183#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
184pub struct PlanformGeometry {
185    /// Area of one fin, m².
186    pub area_m2: f64,
187    /// Centroid, m aft of the root leading edge.
188    pub centroid_x_m: f64,
189    /// Centroid, m outward from the root.
190    pub centroid_span_m: f64,
191}
192
193/// NACA four-digit thickness polynomial `P(ξ)` (Abbott and von Doenhoff, eq. 6.2); the mass code
194/// uses its moments, and the tests integrate it directly.
195#[cfg(test)]
196fn naca_polynomial(xi: f64) -> f64 {
197    0.2969 * xi.sqrt() - 0.1260 * xi - 0.3516 * xi * xi + 0.2843 * xi.powi(3) - 0.1015 * xi.powi(4)
198}
199
200/// `10 ∫₀¹ P dξ`, `10 ∫₀¹ ξ P dξ` and `10 ∫₀¹ ξ² P dξ`, term by term.
201const AIRFOIL_PHI: [f64; 3] = [
202    10.0 * (0.2969 * 2.0 / 3.0 - 0.1260 / 2.0 - 0.3516 / 3.0 + 0.2843 / 4.0 - 0.1015 / 5.0),
203    10.0 * (0.2969 * 2.0 / 5.0 - 0.1260 / 3.0 - 0.3516 / 4.0 + 0.2843 / 5.0 - 0.1015 / 6.0),
204    10.0 * (0.2969 * 2.0 / 7.0 - 0.1260 / 4.0 - 0.3516 / 5.0 + 0.2843 / 6.0 - 0.1015 / 7.0),
205];
206
207/// `1000 ∫₀¹ P³ dξ`, evaluated with mpmath at 30 digits; `airfoil_constants_are_the_integrals`
208/// recomputes it.
209const AIRFOIL_PSI: f64 = 0.472_889_488_894_451_523_708_489_652_762;
210
211/// Chordwise moments `[M_0, M_1, M_2, T]` of a chord from `a` to `b` of thickness `t`.
212fn chord_moments(section: FinCrossSection, a: f64, b: f64, t: f64) -> [f64; 4] {
213    let c = b - a;
214    if c <= 0.0 {
215        return [0.0; 4];
216    }
217    match section {
218        FinCrossSection::Square => [
219            t * c,
220            0.5 * t * (b * b - a * a),
221            t * (b.powi(3) - a.powi(3)) / 3.0,
222            t.powi(3) * c / 12.0,
223        ],
224        FinCrossSection::Airfoil => {
225            let [p0, p1, p2] = AIRFOIL_PHI;
226            [
227                t * c * p0,
228                t * (a * c * p0 + c * c * p1),
229                t * (a * a * c * p0 + 2.0 * a * c * c * p1 + c.powi(3) * p2),
230                t.powi(3) * c * AIRFOIL_PSI / 12.0,
231            ]
232        }
233        FinCrossSection::Rounded => {
234            // A slab of the middle thickness minus what the two rounded edges remove. With
235            // v = a_r − u the depth from the edge's center, the removed thickness is
236            // δ = 2a_r − 2√(a_r² − v²), whose moments from the edge are D_0, D_1, D_2.
237            let mid = t.min(c);
238            let r = 0.5 * mid;
239            let d0 = r * r * (2.0 - PI / 2.0);
240            let d1 = r * d0 - r.powi(3) / 3.0;
241            let d2 = r * r * d0 - PI * r.powi(4) / 8.0;
242            let e0 = 8.0 * r.powi(4) - 1.5 * PI * r.powi(4);
243            let m0 = mid * c - 2.0 * d0;
244            [
245                m0,
246                m0 * 0.5 * (a + b),
247                mid * (b.powi(3) - a.powi(3)) / 3.0
248                    - (a * a * d0 + 2.0 * a * d1 + d2)
249                    - (b * b * d0 - 2.0 * b * d1 + d2),
250                (mid.powi(3) * c - 2.0 * e0) / 12.0,
251            ]
252        }
253    }
254}
255
256impl FinPlanform {
257    /// Root chord, m.
258    pub fn root_chord_m(&self) -> f64 {
259        match self {
260            Self::Trapezoidal { root_chord_m, .. } | Self::Elliptical { root_chord_m, .. } => {
261                *root_chord_m
262            }
263            Self::Freeform { points_m, .. } => points_m.last().map_or(0.0, |p| p[0]),
264        }
265    }
266
267    /// Span, m.
268    pub fn span_m(&self) -> f64 {
269        match self {
270            Self::Trapezoidal { span_m, .. } | Self::Elliptical { span_m, .. } => *span_m,
271            Self::Freeform { points_m, root_m } => {
272                points_m.iter().chain(root_m).fold(0.0, |m, p| m.max(p[1]))
273            }
274        }
275    }
276
277    /// Checks dimensions, and that a freeform outline is a simple polygon, nowhere below the root
278    /// leading edge, that starts and ends on the root.
279    ///
280    /// # Errors
281    ///
282    /// [`DesignError::Domain`] or [`DesignError::Geometry`].
283    pub fn validate(&self) -> Result<(), DesignError> {
284        match self {
285            Self::Trapezoidal {
286                root_chord_m,
287                tip_chord_m,
288                span_m,
289                sweep_m,
290            } => {
291                check_dimension("fin root chord", *root_chord_m, false)?;
292                check_dimension("fin tip chord", *tip_chord_m, true)?;
293                check_dimension("fin span", *span_m, false)?;
294                if !sweep_m.is_finite() {
295                    return Err(DesignError::Domain {
296                        what: "fin sweep",
297                        value: *sweep_m,
298                    });
299                }
300            }
301            Self::Elliptical {
302                root_chord_m,
303                span_m,
304            } => {
305                check_dimension("fin root chord", *root_chord_m, false)?;
306                check_dimension("fin span", *span_m, false)?;
307            }
308            Self::Freeform { points_m, root_m } => validate_outline(points_m, root_m)?,
309        }
310        Ok(())
311    }
312
313    /// The chord intervals `(a, b)` at span `h` (m outward from the root), sorted along `x` (m aft
314    /// of the root leading edge), replacing the contents of `out`. A span at or beyond the tip
315    /// gives none. The planform should be valid ([`FinPlanform::validate`]).
316    pub fn chords_at(&self, h: f64, out: &mut Vec<(f64, f64)>) {
317        out.clear();
318        match self {
319            Self::Trapezoidal {
320                root_chord_m,
321                tip_chord_m,
322                span_m,
323                sweep_m,
324            } => {
325                if (0.0..*span_m).contains(&h) {
326                    let f = h / span_m;
327                    let lead = sweep_m * f;
328                    out.push((lead, lead + root_chord_m + (tip_chord_m - root_chord_m) * f));
329                }
330            }
331            Self::Elliptical {
332                root_chord_m,
333                span_m,
334            } => {
335                if (0.0..*span_m).contains(&h) {
336                    let f = h / span_m;
337                    let chord = root_chord_m * (1.0 - f * f).max(0.0).sqrt();
338                    let lead = 0.5 * (root_chord_m - chord);
339                    out.push((lead, lead + chord));
340                }
341            }
342            Self::Freeform { points_m, root_m } => {
343                let mut crossings: Vec<f64> = Vec::new();
344                let polygon = || points_m.iter().chain(root_m);
345                for (&p, &q) in polygon().zip(polygon().cycle().skip(1)) {
346                    let (lo, hi) = (p[1].min(q[1]), p[1].max(q[1]));
347                    if p[1] != q[1] && h >= lo && h < hi {
348                        crossings.push(p[0] + (h - p[1]) * (q[0] - p[0]) / (q[1] - p[1]));
349                    }
350                }
351                crossings.sort_by(f64::total_cmp);
352                let (pairs, _) = crossings.as_chunks::<2>();
353                out.extend(pairs.iter().map(|&[a, b]| (a, b)));
354            }
355        }
356    }
357
358    /// Span stations where the chord intervals change form: 0, the span, and freeform vertices.
359    fn breakpoints(&self) -> Vec<f64> {
360        let mut points = vec![0.0, self.span_m()];
361        if let Self::Freeform { points_m, root_m } = self {
362            points.extend(points_m.iter().chain(root_m).map(|p| p[1]));
363        }
364        points.sort_by(f64::total_cmp);
365        points.dedup();
366        points
367    }
368
369    /// Area and centroid of the planform.
370    ///
371    /// # Errors
372    ///
373    /// As [`FinPlanform::validate`], or [`DesignError::Numerics`] if an integral fails.
374    pub fn geometry(&self) -> Result<PlanformGeometry, DesignError> {
375        self.validate()?;
376        let scale = self.root_chord_m() + self.span_m();
377        let mut chords = Vec::new();
378        let mut total = [0.0; 3];
379        for pair in self.breakpoints().windows(2) {
380            let piece = integrate(
381                |h| {
382                    self.chords_at(h * scale, &mut chords);
383                    chords.iter().fold([0.0; 3], |acc, &(a, b)| {
384                        let (a, b) = (a / scale, b / scale);
385                        [
386                            acc[0] + (b - a),
387                            acc[1] + 0.5 * (b * b - a * a),
388                            acc[2] + h * (b - a),
389                        ]
390                    })
391                },
392                pair[0] / scale,
393                pair[1] / scale,
394                PLATE,
395            )?;
396            for (sum, value) in total.iter_mut().zip(piece.value) {
397                *sum += value;
398            }
399        }
400        let area = total[0] * scale * scale;
401        if area <= 0.0 {
402            return Err(DesignError::Geometry("the fin has no area".to_owned()));
403        }
404        Ok(PlanformGeometry {
405            area_m2: area,
406            centroid_x_m: scale * total[1] / total[0],
407            centroid_span_m: scale * total[2] / total[0],
408        })
409    }
410}
411
412/// Quadrature settings for plate integrands scaled to order one.
413const PLATE: Tolerance = Tolerance {
414    relative: 1e-12,
415    absolute: 1e-16,
416    max_intervals: 4000,
417};
418
419/// Checks a freeform outline and its root points: finite points, at least three in the outline,
420/// the first at the root leading edge and the last aft of it, none below it, the root's points
421/// running fore between the root's ends, positive area, and no two non-adjacent edges touching.
422fn validate_outline(outline: &[[f64; 2]], root: &[[f64; 2]]) -> Result<(), DesignError> {
423    if outline.len() < 3 {
424        return Err(DesignError::Geometry(format!(
425            "a freeform fin needs at least 3 points, got {}",
426            outline.len()
427        )));
428    }
429    let points: Vec<[f64; 2]> = outline.iter().chain(root).copied().collect();
430    for p in &points {
431        for (what, value) in [("freeform fin x", p[0]), ("freeform fin span", p[1])] {
432            if !value.is_finite() {
433                return Err(DesignError::Domain { what, value });
434            }
435        }
436        if p[1] < 0.0 {
437            return Err(DesignError::Domain {
438                what: "freeform fin span",
439                value: p[1],
440            });
441        }
442    }
443    let (first, last) = (outline[0], outline[outline.len() - 1]);
444    if first != [0.0, 0.0] || last[0] <= 0.0 {
445        return Err(DesignError::Geometry(
446            "a freeform fin must start at the root leading edge [0, 0] and end at the root \
447             trailing edge [c, h] with c > 0"
448                .to_owned(),
449        ));
450    }
451    // The root runs fore from the trailing edge to the leading edge, one point after another.
452    let mut aft = last[0];
453    for p in root {
454        if !(p[0] < aft && p[0] > 0.0) {
455            return Err(DesignError::Geometry(format!(
456                "a freeform fin's root points must run fore, strictly between its trailing edge \
457                 at x = {} m and its leading edge; one is at x = {} m",
458                last[0], p[0]
459            )));
460        }
461        aft = p[0];
462    }
463    let points = points.as_slice();
464    let n = points.len();
465    let edge = |i: usize| (points[i], points[(i + 1) % n]);
466    for i in 0..n {
467        for j in (i + 1)..n {
468            // Adjacent edges share a vertex; the closing edge is adjacent to the first.
469            if j == i + 1 || (i == 0 && j == n - 1) {
470                continue;
471            }
472            let (p, q) = edge(i);
473            let (r, s) = edge(j);
474            if segments_touch(p, q, r, s) {
475                return Err(DesignError::Geometry(format!(
476                    "the freeform fin outline crosses itself (edges {i} and {j})"
477                )));
478            }
479        }
480    }
481    let twice_area: f64 = (0..n)
482        .map(|i| {
483            let (p, q) = edge(i);
484            p[0] * q[1] - q[0] * p[1]
485        })
486        .sum();
487    if twice_area == 0.0 {
488        return Err(DesignError::Geometry("the fin has no area".to_owned()));
489    }
490    // From the root leading edge out along the outline and back along the root, a fin above its
491    // root turns clockwise in `[x, h]`: every outline over a level root does. One that turns the
492    // other way runs below its root, inside the body.
493    if twice_area > 0.0 {
494        return Err(DesignError::Geometry(
495            "the freeform fin outline runs below its root".to_owned(),
496        ));
497    }
498    Ok(())
499}
500
501/// Whether the closed segments `pq` and `rs` share a point.
502fn segments_touch(p: [f64; 2], q: [f64; 2], r: [f64; 2], s: [f64; 2]) -> bool {
503    let cross = |o: [f64; 2], a: [f64; 2], b: [f64; 2]| {
504        (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
505    };
506    let within = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
507        c[0] >= a[0].min(b[0])
508            && c[0] <= a[0].max(b[0])
509            && c[1] >= a[1].min(b[1])
510            && c[1] <= a[1].max(b[1])
511    };
512    let d1 = cross(r, s, p);
513    let d2 = cross(r, s, q);
514    let d3 = cross(p, q, r);
515    let d4 = cross(p, q, s);
516    if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
517        && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
518    {
519        return true;
520    }
521    (d1 == 0.0 && within(r, s, p))
522        || (d2 == 0.0 && within(r, s, q))
523        || (d3 == 0.0 && within(p, q, r))
524        || (d4 == 0.0 && within(p, q, s))
525}
526
527/// Volume integrals of a plate per unit density, about the fin-set origin with the fin at roll 0:
528/// `[V, ∫r, ∫r², ∫x, ∫x², ∫rx, ∫τ²]` (each `dV`).
529type PlateIntegrals = [f64; 7];
530
531impl FinSet {
532    /// How far a root's point may stand off the surface of the nose cone or transition it sits
533    /// on, m: a micron, well inside any build tolerance and far outside the rounding of a file's
534    /// seventeen digits.
535    pub const ROOT_ON_SURFACE_M: f64 = 1e-6;
536
537    /// The most planform, as a share of the fin's, that a root's straight pieces may cut off or
538    /// add across a curved surface: 0.1%, a tenth of the 1% to which `cargo xtask ork` holds a
539    /// design's mass to OpenRocket's. On the cockpit of OpenRocket's *Pods--airframes and
540    /// winglets*, OpenRocket's own 20 pieces come to 0.032%, the `.ork` reader's 64 to 0.0031%,
541    /// and one straight piece, the chord, to 11.4% of its area (12.8% more than the fin's).
542    pub const ROOT_SLIVER_SHARE: f64 = 1e-3;
543
544    /// The root's points from leading to trailing edge: its ends, and a freeform fin's root
545    /// points between them.
546    fn root_points(&self) -> Vec<[f64; 2]> {
547        let mut points = vec![[0.0, 0.0]];
548        match &self.planform {
549            FinPlanform::Freeform { points_m, root_m } => {
550                points.extend(root_m.iter().rev());
551                points.extend(points_m.last());
552            }
553            other => points.push([other.root_chord_m(), 0.0]),
554        }
555        points
556    }
557
558    /// Checks that the set can sit on a body tube: its root is level, ending at `h = 0` with no
559    /// root points.
560    ///
561    /// # Errors
562    ///
563    /// [`DesignError::Geometry`] for a root that isn't level.
564    pub fn check_level_root(&self) -> Result<(), DesignError> {
565        if self.root_points().iter().any(|p| p[1] != 0.0) {
566            return Err(DesignError::Geometry(
567                "a fin set on a body tube needs a level root: a freeform outline that ends at \
568                 h = 0, with every root point there too"
569                    .to_owned(),
570            ));
571        }
572        Ok(())
573    }
574
575    /// How the root sits on a nose cone's or transition's surface, with its leading edge `fore_m`
576    /// aft of the body's forward end: the farthest any of its points stands off it, m, and the
577    /// planform its straight pieces cut off or add across the curve, m². A point at `[x, h]` is on
578    /// the surface when `h = r(x_LE + x) − r(x_LE)`. Each piece's sliver is the parabolic segment
579    /// `(2/3) δ Δx`, with `δ` the surface's distance from the piece's midpoint, which is exact to
580    /// leading order in `Δx` for a smooth surface.
581    pub fn root_on_surface(&self, profile: &Profile, fore_m: f64) -> (f64, f64) {
582        let base = profile.radius_m(fore_m);
583        let miss = |[x, h]: [f64; 2]| h - (profile.radius_m(fore_m + x) - base);
584        let points = self.root_points();
585        // NaN-propagating, so a broken profile or point is refused by name below.
586        let off = points
587            .iter()
588            .map(|&p| miss(p).abs())
589            .fold(0.0, |a: f64, m| {
590                if a.is_nan() || m.is_nan() {
591                    f64::NAN
592                } else {
593                    a.max(m)
594                }
595            });
596        let sliver = points
597            .windows(2)
598            .map(|pair| {
599                let middle = [
600                    0.5 * (pair[0][0] + pair[1][0]),
601                    0.5 * (pair[0][1] + pair[1][1]),
602                ];
603                2.0 / 3.0 * miss(middle).abs() * (pair[1][0] - pair[0][0])
604            })
605            .sum();
606        (off, sliver)
607    }
608    /// The radius of a nose cone's or transition's surface at the root leading edge, `fore_m` aft
609    /// of the body's forward end, after checking that the set can sit there: the root within the
610    /// body's length, its points within [`FinSet::ROOT_ON_SURFACE_M`] of the surface, its pieces'
611    /// slivers within [`FinSet::ROOT_SLIVER_SHARE`] of the fin's area
612    /// ([`FinSet::root_on_surface`]), and no tab or fillet, whose models take a body tube. The
613    /// root's height is measured from that radius.
614    ///
615    /// # Errors
616    ///
617    /// [`DesignError::Geometry`] for a set that can't sit on the body as given.
618    pub fn root_radius_on(&self, profile: &Profile, fore_m: f64) -> Result<f64, DesignError> {
619        let length = profile.length_m();
620        let chord = self.planform.root_chord_m();
621        let slack = Self::ROOT_ON_SURFACE_M;
622        if !(fore_m >= -slack && fore_m + chord <= length + slack) {
623            return Err(DesignError::Geometry(format!(
624                "a fin set's root runs from {fore_m} m to {} m along a body {length} m long; on a \
625                 nose cone or a transition it must stay on it",
626                fore_m + chord
627            )));
628        }
629        if self.tab.is_some() || self.fillet.is_some() {
630            return Err(DesignError::Geometry(
631                "a fin set on a nose cone or a transition with a tab or a fillet: their models \
632                 take a body tube"
633                    .to_owned(),
634            ));
635        }
636        let (off, sliver) = self.root_on_surface(profile, fore_m);
637        // A NaN from a broken profile is refused too.
638        if off.is_nan() || off > slack {
639            return Err(DesignError::Geometry(format!(
640                "a fin set's root stands {off} m off the surface of the nose cone or transition it \
641                 sits on, at one of its points"
642            )));
643        }
644        let share = sliver / self.planform.geometry()?.area_m2;
645        if share.is_nan() || share > Self::ROOT_SLIVER_SHARE {
646            return Err(DesignError::Geometry(format!(
647                "a fin set's root cuts across the curved surface it sits on between its points, by \
648                 {:.3}% of the fin's area, more than {}%: draw it through more points on the \
649                 surface",
650                100.0 * share,
651                100.0 * Self::ROOT_SLIVER_SHARE
652            )));
653        }
654        Ok(profile.radius_m(fore_m))
655    }
656
657    /// The most fins a set may have, the same bound as [`crate::PodSet::MAX_COUNT`].
658    ///
659    /// The set's mass properties build one body per fin and add them up, so a count is an
660    /// allocation and a loop: four billion fins would ask for hundreds of gigabytes and never
661    /// return, where a refusal is an error. 64 is far more than any rocket carries: the most fins
662    /// in one set among the `.ork` designs hpr's checks read is 8 (a one-off count over 78
663    /// designs, not a committed survey), the aerodynamics take
664    /// 1 to 8 (the fin–fin interference factors of the OpenRocket technical documentation 13.05,
665    /// eq. 3.54, from MIL-HDBK-762(MI) p. 5-24, and its tumbling efficiencies, Table 3.4, stop at
666    /// 8), and the `.ork` reader leaves out any part counted more than 64 times. A set of 9 to 64
667    /// fins is weighed; the aerodynamics refuse it with their own error.
668    pub const MAX_COUNT: u32 = 64;
669
670    /// Checks the set's count, dimensions and planform.
671    ///
672    /// # Errors
673    ///
674    /// [`DesignError::Domain`] for no fins or more than [`Self::MAX_COUNT`] (`what` is
675    /// `"fin count (1 to 64)"`, the value the count), a bad dimension, cant or base angle, and
676    /// [`DesignError::Geometry`] for a planform or tab that doesn't close or fit.
677    pub fn validate(&self) -> Result<(), DesignError> {
678        if self.count == 0 || self.count > Self::MAX_COUNT {
679            return Err(DesignError::Domain {
680                what: "fin count (1 to 64)",
681                value: f64::from(self.count),
682            });
683        }
684        self.planform.validate()?;
685        check_dimension("fin thickness", self.thickness_m, false)?;
686        for (what, value) in [
687            ("fin cant", self.cant_rad),
688            ("fin base angle", self.base_angle_rad),
689        ] {
690            if !value.is_finite() {
691                return Err(DesignError::Domain { what, value });
692            }
693        }
694        if let Some(fillet) = &self.fillet {
695            check_dimension("fin fillet radius", fillet.radius_m, true)?;
696        }
697        if let Some(tab) = self.tab {
698            check_dimension("fin tab height", tab.height_m, false)?;
699            check_dimension("fin tab length", tab.length_m, false)?;
700            if !tab.offset_m.is_finite() {
701                return Err(DesignError::Domain {
702                    what: "fin tab offset",
703                    value: tab.offset_m,
704                });
705            }
706            let root = self.planform.root_chord_m();
707            if tab.offset_m < 0.0 || tab.offset_m + tab.length_m > root * (1.0 + 1e-12) {
708                return Err(DesignError::Geometry(format!(
709                    "a fin tab must lie along the root chord ({root} m)"
710                )));
711            }
712        }
713        Ok(())
714    }
715
716    /// The planform integrals of one fin, without its tab.
717    fn fin_integrals(&self, body_radius_m: f64) -> Result<PlateIntegrals, DesignError> {
718        let scale = self.planform.root_chord_m() + self.planform.span_m();
719        let rb = body_radius_m / scale;
720        let t = self.thickness_m / scale;
721        let mut chords = Vec::new();
722        let mut total = [0.0; 7];
723        for pair in self.planform.breakpoints().windows(2) {
724            let piece = integrate(
725                |h| {
726                    self.planform.chords_at(h * scale, &mut chords);
727                    let mut m = [0.0; 4];
728                    for &(a, b) in &chords {
729                        let c = chord_moments(self.cross_section, a / scale, b / scale, t);
730                        for k in 0..4 {
731                            m[k] += c[k];
732                        }
733                    }
734                    let r = rb + h;
735                    [m[0], r * m[0], r * r * m[0], m[1], m[2], r * m[1], m[3]]
736                },
737                pair[0] / scale,
738                pair[1] / scale,
739                PLATE,
740            )?;
741            for (sum, value) in total.iter_mut().zip(piece.value) {
742                *sum += value;
743            }
744        }
745        // Undo the scaling: lengths to the powers 3, 4, 5, 4, 5, 5, 5.
746        let powers = [3, 4, 5, 4, 5, 5, 5];
747        Ok(std::array::from_fn(|k| total[k] * scale.powi(powers[k])))
748    }
749
750    /// The integrals of the tab slab `offset ≤ x ≤ offset + length`, `−height ≤ h ≤ 0`.
751    fn tab_integrals(&self, body_radius_m: f64) -> PlateIntegrals {
752        let Some(tab) = self.tab else {
753            return [0.0; 7];
754        };
755        let t = self.thickness_m;
756        let (x0, x1) = (tab.offset_m, tab.offset_m + tab.length_m);
757        let (r0, r1) = (body_radius_m - tab.height_m, body_radius_m);
758        let (h, l) = (tab.height_m, tab.length_m);
759        let r_first = 0.5 * (r1 * r1 - r0 * r0);
760        let x_first = 0.5 * (x1 * x1 - x0 * x0);
761        [
762            t * l * h,
763            t * l * r_first,
764            t * l * (r1.powi(3) - r0.powi(3)) / 3.0,
765            t * h * x_first,
766            t * h * (x1.powi(3) - x0.powi(3)) / 3.0,
767            t * x_first * r_first,
768            t.powi(3) / 12.0 * l * h,
769        ]
770    }
771
772    /// Mass properties of one fin (and its tab) at roll angle 0 and no cant, in the fin-set
773    /// frame, on a body of radius `body_radius_m`.
774    ///
775    /// # Errors
776    ///
777    /// As [`FinSet::validate`], plus [`DesignError::Domain`] for a negative body radius or a
778    /// fillet more than [`FILLET_RATIO_MAX`] times the body radius, and
779    /// [`DesignError::Numerics`] if an integral fails.
780    pub fn single_fin(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
781        self.validate()?;
782        check_dimension("fin body radius", body_radius_m, true)?;
783        if let Some(tab) = self.tab
784            && tab.height_m > body_radius_m
785        {
786            return Err(DesignError::Geometry(format!(
787                "a fin tab must reach no deeper than the body radius ({body_radius_m} m)"
788            )));
789        }
790        let density = self.material.bulk_kg_m3("fin set")?;
791        let fin = self.fin_integrals(body_radius_m)?;
792        let tab = self.tab_integrals(body_radius_m);
793        let [v, r1, r2, x1, x2, rx, tau2] = std::array::from_fn(|k| fin[k] + tab[k]);
794        if v <= 0.0 {
795            return Err(DesignError::Geometry("the fin has no volume".to_owned()));
796        }
797        let mass = density * v;
798        // Points sit at (r, τ, −x): I_xz = −∫ x_B z_B dm = ∫ r x dm.
799        let about_origin = DMat3::from_cols(
800            DVec3::new(tau2 + x2, 0.0, rx),
801            DVec3::new(0.0, r2 + x2, 0.0),
802            DVec3::new(rx, 0.0, r2 + tau2),
803        ) * density;
804        let cg = DVec3::new(r1 / v, 0.0, -x1 / v);
805        let at_origin = MassProperties {
806            mass_kg: mass,
807            cg_m: cg,
808            inertia_kg_m2: DMat3::ZERO,
809        };
810        // Move the tensor from the origin to the center: I_cg = I_o − m(|c|²E − c cᵀ).
811        let inertia = about_origin - at_origin.inertia_about(DVec3::ZERO);
812        let plate = MassProperties {
813            inertia_kg_m2: inertia,
814            ..at_origin
815        };
816        let fin = match self.fillets(body_radius_m)? {
817            Some(fillets) => MassProperties::combine([&plate, &fillets]),
818            None => plate,
819        };
820        if self.cant_rad == 0.0 {
821            return Ok(fin);
822        }
823        let pivot = DVec3::new(body_radius_m, 0.0, -0.5 * self.planform.root_chord_m());
824        Ok(fin
825            .translated(-pivot)
826            .rotated(DQuat::from_rotation_x(self.cant_rad))
827            .translated(pivot))
828    }
829
830    /// Mass properties of the two fillets along one fin's root, at roll angle 0 and no cant, in
831    /// the fin-set frame; `None` for a fin with no fillets or fillets of no radius. The pair is a
832    /// prism of length `c_r` whose section is [`fillet_section`] on each side of the fin's plane,
833    /// so with `A`, `S_x`, `S_xx`, `S_yy` one side's area and moments about the body axis:
834    ///
835    /// ```text
836    /// m = 2ρA c_r,  I_xx = 2ρ(S_yy c_r + A c_r³/3),  I_yy = 2ρ(S_xx c_r + A c_r³/3),
837    /// I_zz = 2ρ(S_xx + S_yy) c_r,  I_xz = ρ S_x c_r²
838    /// ```
839    ///
840    /// about the origin, then moved to the center, `(S_x/A, 0, −c_r/2)`.
841    fn fillets(&self, body_radius_m: f64) -> Result<Option<MassProperties>, DesignError> {
842        let Some(fillet) = self.fillet.as_ref().filter(|f| f.radius_m > 0.0) else {
843            return Ok(None);
844        };
845        let density = fillet.material.bulk_kg_m3("fin fillet")?;
846        // No body, no fillet: the section is none ([`fillet_section`]).
847        if body_radius_m == 0.0 {
848            return Ok(None);
849        }
850        // The section is a triangle less two sectors, and far from real fillets the difference
851        // loses digits to cancellation. At the bounds below it keeps 10 (a millionth of the
852        // body's radius: 8.6e-11 off 60-digit arithmetic) and 12 (a thousand times: 7.6e-13).
853        // A thinner fillet weighs under 1e-12 of the body's radius squared per meter of root:
854        // none. A wider one is refused, not weighed on digits that are noise.
855        let ratio = fillet.radius_m / body_radius_m;
856        if ratio < 1e-6 {
857            return Ok(None);
858        }
859        if ratio > FILLET_RATIO_MAX {
860            return Err(DesignError::Domain {
861                what: "fin fillet radius over the body radius (at most 1000)",
862                value: ratio,
863            });
864        }
865        let [area, sx, sxx, syy] = fillet_section(body_radius_m, fillet.radius_m);
866        // Invariant between the bounds above: the section is positive and finite there.
867        if !(area > 0.0 && [sx, sxx, syy].iter().all(|v| v.is_finite())) {
868            return Err(DesignError::Domain {
869                what: "fin fillet section area",
870                value: area,
871            });
872        }
873        let l = self.planform.root_chord_m();
874        let mass = 2.0 * density * area * l;
875        if mass == 0.0 {
876            return Ok(None);
877        }
878        let ixz = density * sx * l * l;
879        let about_origin = DMat3::from_cols(
880            DVec3::new(2.0 * density * (syy * l + area * l.powi(3) / 3.0), 0.0, ixz),
881            DVec3::new(0.0, 2.0 * density * (sxx * l + area * l.powi(3) / 3.0), 0.0),
882            DVec3::new(ixz, 0.0, 2.0 * density * (sxx + syy) * l),
883        );
884        let at_origin = MassProperties {
885            mass_kg: mass,
886            cg_m: DVec3::new(sx / area, 0.0, -0.5 * l),
887            inertia_kg_m2: DMat3::ZERO,
888        };
889        Ok(Some(MassProperties {
890            inertia_kg_m2: about_origin - at_origin.inertia_about(DVec3::ZERO),
891            ..at_origin
892        }))
893    }
894
895    /// Mass properties of the whole set in its frame, on a body of radius `body_radius_m`.
896    ///
897    /// # Errors
898    ///
899    /// As [`FinSet::single_fin`].
900    pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
901        let fin = self.single_fin(body_radius_m)?;
902        let fins: Vec<MassProperties> = (0..self.count)
903            .map(|k| {
904                fin.rolled(self.base_angle_rad + 2.0 * PI * f64::from(k) / f64::from(self.count))
905            })
906            .collect();
907        Ok(MassProperties::combine(&fins))
908    }
909}
910
911/// The widest fin fillet weighed, as a multiple of the body radius ([`FinSet::single_fin`]).
912pub const FILLET_RATIO_MAX: f64 = 1e3;
913
914/// One fillet's section on the `+y` side of a fin in the plane `y = 0`, with `x` outward along
915/// the fin: its area and moments `[A, S_x, S_xx, S_yy]` about the body axis (`S_x = ∫ x dA`,
916/// `S_xx = ∫ x² dA`, `S_yy = ∫ y² dA`), m², m³, m⁴, m⁴. The section is the triangle
917/// `(0, 0), (c, 0), (c, r)` less two circular sectors, the body's about the axis from `0` to `θ`
918/// and the fillet's about `(c, r)` from `θ − π` to `−π/2` (module docs). A body of no radius
919/// gives a section of none.
920pub(crate) fn fillet_section(body_radius_m: f64, radius_m: f64) -> [f64; 4] {
921    let (rb, r) = (body_radius_m, radius_m);
922    let c = (rb * rb + 2.0 * rb * r).sqrt();
923    let theta = r.atan2(c);
924    // The triangle: vertices (0, 0), (c, 0), (c, r).
925    let triangle_area = 0.5 * c * r;
926    let triangle = [
927        triangle_area,
928        triangle_area * 2.0 * c / 3.0,
929        triangle_area * c * c / 2.0,
930        triangle_area * r * r / 6.0,
931    ];
932    let body = sector([0.0, 0.0], rb, 0.0, theta);
933    let joint = sector([c, r], r, theta - PI, -0.5 * PI);
934    std::array::from_fn(|k| triangle[k] - body[k] - joint[k])
935}
936
937/// A circular sector about `center`, of radius `rho`, from angle `from` to `to` (`to ≥ from`):
938/// `[A, S_x, S_xx, S_yy]` about the origin. With `Δ = to − from` and `σ = to + from`, the moments
939/// about the center are `ρ³/3 · 2 sin(Δ/2) [cos(σ/2), sin(σ/2)]` and `ρ⁴/8 · (Δ ± sin Δ cos σ)`,
940/// the second written as `(Δ − sin Δ) + sin Δ (1 ± cos σ)` so that the body's thin sector, whose
941/// `Δ` and `σ` are both small, loses no digits to cancellation.
942fn sector([x0, y0]: [f64; 2], rho: f64, from: f64, to: f64) -> [f64; 4] {
943    let (span, sum) = (to - from, to + from);
944    let area = 0.5 * rho * rho * span;
945    let chord = 2.0 * (0.5 * span).sin() * rho.powi(3) / 3.0;
946    let (sx, sy) = (chord * (0.5 * sum).cos(), chord * (0.5 * sum).sin());
947    let (lean, sine) = (span_less_sine(span), span.sin());
948    let (sxx, syy) = (
949        rho.powi(4) / 8.0 * (lean + sine * 2.0 * (0.5 * sum).cos().powi(2)),
950        rho.powi(4) / 8.0 * (lean + sine * 2.0 * (0.5 * sum).sin().powi(2)),
951    );
952    [
953        area,
954        sx + x0 * area,
955        sxx + 2.0 * x0 * sx + x0 * x0 * area,
956        syy + 2.0 * y0 * sy + y0 * y0 * area,
957    ]
958}
959
960/// `x − sin x`, by its Taylor series below `x = 0.25`, where the difference would cancel: the
961/// terms through `x¹⁵` leave a remainder under 1e-18 of the sum.
962fn span_less_sine(x: f64) -> f64 {
963    if x.abs() >= 0.25 {
964        return x - x.sin();
965    }
966    // x³/3! − x⁵/5! + …, each term the last times −x²/((2k)(2k+1)).
967    let mut term = x.powi(3) / 6.0;
968    let mut sum = 0.0;
969    for k in 2..=8 {
970        sum += term;
971        term *= -x * x / f64::from((2 * k) * (2 * k + 1));
972    }
973    sum
974}
975
976/// Tube fins: open tubes parallel to the body, touching it, spaced evenly around it.
977#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
978#[serde(deny_unknown_fields)]
979pub struct TubeFinSet {
980    /// Number of tubes, 1 to 64 (`TubeFinSet::MAX_COUNT`).
981    pub count: u32,
982    /// Length, m.
983    pub length_m: f64,
984    /// Outer radius, m.
985    pub outer_radius_m: f64,
986    /// Wall thickness, m.
987    pub thickness_m: f64,
988    /// Roll angle of the first tube's axis from `x_B` toward `y_B`, rad.
989    #[serde(default)]
990    pub base_angle_rad: f64,
991    /// Material (bulk).
992    pub material: Material,
993}
994
995impl TubeFinSet {
996    /// The most tubes a set may have: as [`FinSet::MAX_COUNT`], a bound on the bodies its mass
997    /// properties build one by one, far above any rocket's (the most tubes in one set among the
998    /// `.ork` designs read is 6, a one-off count over 78 designs, not a committed survey).
999    pub const MAX_COUNT: u32 = 64;
1000
1001    /// The outer radius of `count` tubes that close the ring around a body of radius
1002    /// `body_radius_m`, each touching the body and its two neighbours, which is the radius
1003    /// OpenRocket 24.12 gives a tube fin set written `auto` ([ADR-098][adr-098]).
1004    ///
1005    /// The axes of `N` tubes of radius `r` sit on a circle of radius `R + r`, `2π/N` apart, so
1006    /// neighbouring axes are a chord `2(R + r) sin(π/N)` apart. Touching means that chord is `2r`:
1007    ///
1008    /// `r = R sin(π/N) / (1 − sin(π/N))`.
1009    ///
1010    /// For one or two tubes `sin(π/N)` is 0 or 1 and no finite ring closes; OpenRocket 24.12 gives
1011    /// those the body's radius, measured on its probes on 20 and 50 mm bodies, and so does this;
1012    /// so does a count of none, which [`Self::mass_properties`] refuses. On a 50 mm body, six
1013    /// tubes are 50 mm (`sin(π/6) = 1/2`), four are 120.7 mm and eight are 31.0 mm.
1014    ///
1015    /// [adr-098]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-098-a-tube-fin-sets-automatic-radius-read-as-openrocket-reads-it-2026-09-28
1016    pub fn closing_radius_m(body_radius_m: f64, count: u32) -> f64 {
1017        if count < 3 {
1018            return body_radius_m;
1019        }
1020        let s = (PI / f64::from(count)).sin();
1021        body_radius_m * s / (1.0 - s)
1022    }
1023
1024    /// Mass properties in the set's frame (origin on the body axis at the tubes' forward end), on
1025    /// a body of radius `body_radius_m`. Each tube is a hollow cylinder,
1026    /// `I_a = m(R² + r²)/2` and `I_t = m((R² + r²)/4 + L²/12)`, with its axis at `R_b + R`.
1027    ///
1028    /// # Errors
1029    ///
1030    /// [`DesignError::Domain`] for a bad dimension, or a count of none or more than
1031    /// [`Self::MAX_COUNT`] (`what` is `"tube fin count (1 to 64)"`), [`DesignError::Geometry`] for
1032    /// a wall thicker than the radius, and material errors.
1033    pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
1034        if self.count == 0 || self.count > Self::MAX_COUNT {
1035            return Err(DesignError::Domain {
1036                what: "tube fin count (1 to 64)",
1037                value: f64::from(self.count),
1038            });
1039        }
1040        check_dimension("body radius", body_radius_m, true)?;
1041        if !self.base_angle_rad.is_finite() {
1042            return Err(DesignError::Domain {
1043                what: "tube fin base angle",
1044                value: self.base_angle_rad,
1045            });
1046        }
1047        let density = self.material.bulk_kg_m3("tube fin set")?;
1048        let tube = crate::parts::hollow_cylinder(
1049            "tube fin",
1050            density,
1051            self.length_m,
1052            self.outer_radius_m,
1053            self.thickness_m,
1054        )?;
1055        let one = tube.translated(DVec3::new(body_radius_m + self.outer_radius_m, 0.0, 0.0));
1056        let tubes: Vec<MassProperties> = (0..self.count)
1057            .map(|k| {
1058                one.rolled(self.base_angle_rad + 2.0 * PI * f64::from(k) / f64::from(self.count))
1059            })
1060            .collect();
1061        Ok(MassProperties::combine(&tubes))
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068    use hpr_core::quadrature::integrate_scalar;
1069
1070    fn close(got: f64, want: f64, rel: f64, what: &str) {
1071        let err = if want == 0.0 {
1072            got.abs()
1073        } else {
1074            ((got - want) / want).abs()
1075        };
1076        assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
1077    }
1078
1079    fn mat_close(a: DMat3, b: DMat3, rel: f64, what: &str) {
1080        let scale = b.to_cols_array().iter().fold(0.0f64, |m, v| m.max(v.abs()));
1081        let diff = (a - b)
1082            .to_cols_array()
1083            .iter()
1084            .fold(0.0f64, |m, v| m.max(v.abs()));
1085        assert!(diff <= rel * scale, "{what}: {a:?}\nvs\n{b:?}");
1086    }
1087
1088    fn ply() -> Material {
1089        Material::bulk("plywood", 630.0)
1090    }
1091
1092    fn rectangle(chord: f64, span: f64) -> FinPlanform {
1093        FinPlanform::Trapezoidal {
1094            root_chord_m: chord,
1095            tip_chord_m: chord,
1096            span_m: span,
1097            sweep_m: 0.0,
1098        }
1099    }
1100
1101    fn set(count: u32, planform: FinPlanform, thickness: f64) -> FinSet {
1102        FinSet {
1103            count,
1104            planform,
1105            thickness_m: thickness,
1106            cross_section: FinCrossSection::Square,
1107            tab: None,
1108            fillet: None,
1109            cant_rad: 0.0,
1110            base_angle_rad: 0.0,
1111            material: ply(),
1112        }
1113    }
1114
1115    #[test]
1116    fn airfoil_constants_are_the_integrals() {
1117        // With ξ = u², dξ = 2u du, and the integrands are polynomials in u.
1118        let tol = Tolerance::default();
1119        let p = naca_polynomial;
1120        for (k, want) in AIRFOIL_PHI.iter().enumerate() {
1121            let power = i32::try_from(2 * k).unwrap();
1122            let got = 10.0
1123                * integrate_scalar(|u| u.powi(power) * p(u * u) * 2.0 * u, 0.0, 1.0, tol).unwrap();
1124            close(got, *want, 1e-14, "phi");
1125        }
1126        let psi = 1000.0 * integrate_scalar(|u| p(u * u).powi(3) * 2.0 * u, 0.0, 1.0, tol).unwrap();
1127        close(psi, AIRFOIL_PSI, 1e-14, "psi");
1128    }
1129
1130    #[test]
1131    fn chord_moments_match_quadrature_of_each_section() {
1132        // The references ask for the tightest tolerance the quadrature allows (50 ε relative).
1133        let tol = Tolerance {
1134            relative: 0.0,
1135            absolute: 0.0,
1136            max_intervals: 4000,
1137        };
1138        let t: f64 = 0.004;
1139        // (a, b) wider than the thickness, and a sliver narrower than it (a circle of diameter c).
1140        for (a, b) in [(0.02, 0.17), (0.05, 0.053)] {
1141            let c: f64 = b - a;
1142            let rounded = |x: f64| {
1143                let mid = t.min(c);
1144                let r = 0.5 * mid;
1145                let u = (x - a).min(b - x);
1146                if u >= r {
1147                    mid
1148                } else {
1149                    2.0 * (r * r - (r - u).powi(2)).max(0.0).sqrt()
1150                }
1151            };
1152            let airfoil = |x: f64| 10.0 * t * naca_polynomial((x - a) / c);
1153            let square = |_x: f64| t;
1154            let sections: [(FinCrossSection, &dyn Fn(f64) -> f64); 3] = [
1155                (FinCrossSection::Square, &square),
1156                (FinCrossSection::Rounded, &rounded),
1157                (FinCrossSection::Airfoil, &airfoil),
1158            ];
1159            // Exact references: split where the edges meet the flat, and substitute x = a + v²
1160            // (and x = b − v²) at the ends, where the thickness grows like a square root.
1161            let edge = 0.5 * t.min(c);
1162            let reference = |f: &dyn Fn(f64) -> f64| -> f64 {
1163                let fore = integrate_scalar(|v| f(a + v * v) * 2.0 * v, 0.0, edge.sqrt(), tol);
1164                let aft = integrate_scalar(|v| f(b - v * v) * 2.0 * v, 0.0, edge.sqrt(), tol);
1165                let middle = integrate_scalar(f, a + edge, b - edge, tol);
1166                fore.unwrap() + aft.unwrap() + middle.unwrap()
1167            };
1168            for (section, thickness) in sections {
1169                let got = chord_moments(section, a, b, t);
1170                for (k, &moment) in got.iter().take(3).enumerate() {
1171                    let power = i32::try_from(k).unwrap();
1172                    let want = reference(&|x: f64| x.powi(power) * thickness(x));
1173                    close(
1174                        moment,
1175                        want,
1176                        1e-13,
1177                        &format!("{section:?} M{k} on [{a}, {b}]"),
1178                    );
1179                }
1180                let want = reference(&|x: f64| thickness(x).powi(3) / 12.0);
1181                close(got[3], want, 1e-13, &format!("{section:?} T on [{a}, {b}]"));
1182            }
1183        }
1184        // The rounded section of a wide chord loses (1 − π/4) t² of area.
1185        let m = chord_moments(FinCrossSection::Rounded, 0.0, 0.1, t);
1186        close(
1187            m[0],
1188            0.1 * t - (1.0 - PI / 4.0) * t * t,
1189            1e-14,
1190            "rounded area",
1191        );
1192    }
1193
1194    #[test]
1195    fn planform_areas_and_centroids_are_the_closed_forms() {
1196        let (cr, ct, s, xt) = (0.15, 0.06, 0.1, 0.07);
1197        let trapezoid = FinPlanform::Trapezoidal {
1198            root_chord_m: cr,
1199            tip_chord_m: ct,
1200            span_m: s,
1201            sweep_m: xt,
1202        };
1203        let g = trapezoid.geometry().unwrap();
1204        close(g.area_m2, 0.5 * s * (cr + ct), 1e-13, "trapezoid area");
1205        close(
1206            g.centroid_x_m,
1207            (xt * (cr + 2.0 * ct) + cr * cr + cr * ct + ct * ct) / (3.0 * (cr + ct)),
1208            1e-13,
1209            "trapezoid centroid x",
1210        );
1211        close(
1212            g.centroid_span_m,
1213            s * (cr + 2.0 * ct) / (3.0 * (cr + ct)),
1214            1e-13,
1215            "trapezoid centroid h",
1216        );
1217        // The same outline as a freeform polygon.
1218        let freeform = FinPlanform::Freeform {
1219            points_m: vec![[0.0, 0.0], [xt, s], [xt + ct, s], [cr, 0.0]],
1220            root_m: Vec::new(),
1221        };
1222        let f = freeform.geometry().unwrap();
1223        close(f.area_m2, g.area_m2, 1e-13, "freeform area");
1224        close(f.centroid_x_m, g.centroid_x_m, 1e-13, "freeform centroid x");
1225        close(
1226            f.centroid_span_m,
1227            g.centroid_span_m,
1228            1e-13,
1229            "freeform centroid h",
1230        );
1231        // Half ellipse: area π c_r s / 4, centroid at mid-chord and 4s/3π out.
1232        let e = FinPlanform::Elliptical {
1233            root_chord_m: cr,
1234            span_m: s,
1235        }
1236        .geometry()
1237        .unwrap();
1238        close(e.area_m2, PI * cr * s / 4.0, 1e-12, "ellipse area");
1239        close(e.centroid_x_m, cr / 2.0, 1e-12, "ellipse centroid x");
1240        close(
1241            e.centroid_span_m,
1242            4.0 * s / (3.0 * PI),
1243            1e-12,
1244            "ellipse centroid h",
1245        );
1246    }
1247
1248    #[test]
1249    fn a_rectangular_fin_set_matches_box_inertia_by_hand() {
1250        let (c, s, t, rb, rho) = (0.12, 0.08, 0.003, 0.04, 630.0);
1251        let m = rho * c * s * t;
1252        let d = rb + s / 2.0;
1253        // One fin: a box with s along x, t along y, c along z, centered at (d, 0, −c/2).
1254        let one = set(1, rectangle(c, s), t).single_fin(rb).unwrap();
1255        close(one.mass_kg, m, 1e-13, "mass");
1256        assert!((one.cg_m - DVec3::new(d, 0.0, -c / 2.0)).length() < 1e-15);
1257        let box_inertia = DMat3::from_diagonal(DVec3::new(
1258            m * (t * t + c * c) / 12.0,
1259            m * (s * s + c * c) / 12.0,
1260            m * (s * s + t * t) / 12.0,
1261        ));
1262        mat_close(one.inertia_kg_m2, box_inertia, 1e-12, "one fin");
1263        // Four fins: two along ±x and two along ±y.
1264        let four = set(4, rectangle(c, s), t).mass_properties(rb).unwrap();
1265        close(four.mass_kg, 4.0 * m, 1e-13, "four fins");
1266        assert!(four.cg_m.truncate().length() < 1e-15);
1267        let transverse =
1268            2.0 * m * (t * t + c * c) / 12.0 + 2.0 * (m * (s * s + c * c) / 12.0 + m * d * d);
1269        let axial = 4.0 * (m * (s * s + t * t) / 12.0 + m * d * d);
1270        mat_close(
1271            four.inertia_kg_m2,
1272            DMat3::from_diagonal(DVec3::new(transverse, transverse, axial)),
1273            1e-12,
1274            "four fins",
1275        );
1276        // Three fins are isotropic across the axis too; two are not.
1277        let three = set(3, rectangle(c, s), t)
1278            .mass_properties(rb)
1279            .unwrap()
1280            .inertia_kg_m2;
1281        close(
1282            three.x_axis.x,
1283            three.y_axis.y,
1284            1e-12,
1285            "three fins isotropic",
1286        );
1287        assert!(three.y_axis.x.abs() < 1e-12 * three.x_axis.x);
1288        let two = set(2, rectangle(c, s), t)
1289            .mass_properties(rb)
1290            .unwrap()
1291            .inertia_kg_m2;
1292        assert!((two.x_axis.x - two.y_axis.y).abs() > 1e-4 * two.y_axis.y);
1293
1294        // Roots on the axis, as on a pod of no length (M1.13b2): the same boxes, `d = s/2` out.
1295        let d = s / 2.0;
1296        let on_axis = set(4, rectangle(c, s), t).mass_properties(0.0).unwrap();
1297        let transverse =
1298            2.0 * m * (t * t + c * c) / 12.0 + 2.0 * (m * (s * s + c * c) / 12.0 + m * d * d);
1299        let axial = 4.0 * (m * (s * s + t * t) / 12.0 + m * d * d);
1300        mat_close(
1301            on_axis.inertia_kg_m2,
1302            DMat3::from_diagonal(DVec3::new(transverse, transverse, axial)),
1303            1e-12,
1304            "four fins from the axis",
1305        );
1306    }
1307
1308    #[test]
1309    fn cant_turns_the_fin_about_its_span_axis() {
1310        let (c, s, t, rb, rho) = (0.12, 0.08, 0.003, 0.04, 630.0);
1311        let m = rho * c * s * t;
1312        // Canted 90°, the chord lies along y: a box with s along x, c along y, t along z, whose
1313        // center stays at the pivot's station because the pivot is the root mid-chord.
1314        let mut fins = set(1, rectangle(c, s), t);
1315        fins.cant_rad = PI / 2.0;
1316        let turned = fins.single_fin(rb).unwrap();
1317        assert!((turned.cg_m - DVec3::new(rb + s / 2.0, 0.0, -c / 2.0)).length() < 1e-15);
1318        let expected = DMat3::from_diagonal(DVec3::new(
1319            m * (c * c + t * t) / 12.0,
1320            m * (s * s + t * t) / 12.0,
1321            m * (s * s + c * c) / 12.0,
1322        ));
1323        mat_close(turned.inertia_kg_m2, expected, 1e-12, "canted 90°");
1324        // A small cant only couples x and... keeps mass and center and the trace.
1325        fins.cant_rad = 0.05;
1326        let small = fins.single_fin(rb).unwrap();
1327        let flat = set(1, rectangle(c, s), t).single_fin(rb).unwrap();
1328        close(small.mass_kg, flat.mass_kg, 1e-15, "mass");
1329        let trace = |i: DMat3| i.x_axis.x + i.y_axis.y + i.z_axis.z;
1330        close(
1331            trace(small.inertia_kg_m2),
1332            trace(flat.inertia_kg_m2),
1333            1e-12,
1334            "trace",
1335        );
1336    }
1337
1338    #[test]
1339    fn positive_cant_turns_the_leading_edge_toward_negative_y() {
1340        // A swept fin's centroid lies aft of the root mid-chord, so turning it by δ about the span
1341        // axis through the mid-chord moves the centroid to y = (x̄ − c_r/2) sin δ and
1342        // z = −c_r/2 − (x̄ − c_r/2) cos δ; the leading edge (x = 0) goes to y = −(c_r/2) sin δ.
1343        let (cr, ct, s, xt) = (0.15, 0.05, 0.1, 0.09);
1344        let planform = FinPlanform::Trapezoidal {
1345            root_chord_m: cr,
1346            tip_chord_m: ct,
1347            span_m: s,
1348            sweep_m: xt,
1349        };
1350        let centroid = planform.geometry().unwrap().centroid_x_m;
1351        assert!(centroid > cr / 2.0);
1352        let mut fins = set(1, planform, 0.003);
1353        let delta: f64 = 0.2;
1354        fins.cant_rad = delta;
1355        let fin = fins.single_fin(0.04).unwrap();
1356        close(
1357            fin.cg_m.y,
1358            (centroid - cr / 2.0) * delta.sin(),
1359            1e-12,
1360            "centroid y",
1361        );
1362        close(
1363            fin.cg_m.z,
1364            -cr / 2.0 - (centroid - cr / 2.0) * delta.cos(),
1365            1e-12,
1366            "centroid z",
1367        );
1368        assert!(fin.cg_m.y > 0.0);
1369        let leading_edge = DQuat::from_rotation_x(delta) * DVec3::new(0.04, 0.0, cr / 2.0);
1370        assert!(leading_edge.y < 0.0);
1371    }
1372
1373    #[test]
1374    fn tabs_must_fit_the_root_and_the_body() {
1375        let mut fins = set(3, rectangle(0.1, 0.05), 0.003);
1376        // Along the root is checked without a body; the depth needs one.
1377        fins.tab = Some(FinTab {
1378            height_m: 0.01,
1379            length_m: 0.08,
1380            offset_m: 0.03,
1381        });
1382        assert!(matches!(fins.validate(), Err(DesignError::Geometry(_))));
1383        for tab in [
1384            FinTab {
1385                height_m: 0.05,
1386                length_m: 0.02,
1387                offset_m: 0.0,
1388            },
1389            FinTab {
1390                height_m: 0.01,
1391                length_m: 0.02,
1392                offset_m: -0.01,
1393            },
1394            FinTab {
1395                height_m: 0.01,
1396                length_m: 0.08,
1397                offset_m: 0.03,
1398            },
1399        ] {
1400            fins.tab = Some(tab);
1401            assert!(
1402                matches!(fins.mass_properties(0.03), Err(DesignError::Geometry(_))),
1403                "{tab:?}"
1404            );
1405        }
1406        fins.tab = Some(FinTab {
1407            height_m: 0.03,
1408            length_m: 0.1,
1409            offset_m: 0.0,
1410        });
1411        fins.mass_properties(0.03).unwrap();
1412    }
1413
1414    #[test]
1415    fn a_swept_fin_has_the_parallel_axis_product_of_inertia() {
1416        // A single trapezoidal fin: I_xz about the origin is ∫ r x dm, so about the center it is
1417        // ∫ r x dm − m r̄ x̄ (with z = −x, I_xz = −∫ x_B z_B dm). Check against direct quadrature of
1418        // the planform.
1419        let (cr, ct, s, xt, t, rb, rho) = (0.15, 0.05, 0.1, 0.09, 0.004, 0.05, 1800.0);
1420        let planform = FinPlanform::Trapezoidal {
1421            root_chord_m: cr,
1422            tip_chord_m: ct,
1423            span_m: s,
1424            sweep_m: xt,
1425        };
1426        let mut fins = set(1, planform, t);
1427        fins.material = Material::bulk("G10", rho);
1428        let fin = fins.single_fin(rb).unwrap();
1429        let tol = Tolerance::default();
1430        let lead = |h: f64| xt * h / s;
1431        let chord = |h: f64| cr + (ct - cr) * h / s;
1432        let area = integrate_scalar(chord, 0.0, s, tol).unwrap();
1433        let mx = integrate_scalar(|h| chord(h) * (lead(h) + chord(h) / 2.0), 0.0, s, tol).unwrap();
1434        let mr = integrate_scalar(|h| chord(h) * (rb + h), 0.0, s, tol).unwrap();
1435        let mrx = integrate_scalar(
1436            |h| (rb + h) * chord(h) * (lead(h) + chord(h) / 2.0),
1437            0.0,
1438            s,
1439            tol,
1440        )
1441        .unwrap();
1442        let m = rho * t * area;
1443        close(fin.mass_kg, m, 1e-13, "mass");
1444        let product = rho * t * mrx - m * (mr / area) * (mx / area);
1445        close(fin.inertia_kg_m2.z_axis.x, product, 1e-11, "I_xz");
1446        close(fin.inertia_kg_m2.x_axis.z, product, 1e-11, "I_zx");
1447    }
1448
1449    #[test]
1450    fn a_closing_ring_of_tubes_touches_the_body_and_its_neighbours() {
1451        let body = 0.05;
1452        for count in 3..=12 {
1453            let r = TubeFinSet::closing_radius_m(body, count);
1454            assert!(r > 0.0 && r.is_finite(), "{count}: {r}");
1455            // Neighbouring axes, on the circle of radius R + r, are 2r apart: the tubes touch.
1456            let step = 2.0 * PI / f64::from(count);
1457            let (a, b) = (
1458                DVec3::new(body + r, 0.0, 0.0),
1459                DVec3::new((body + r) * step.cos(), (body + r) * step.sin(), 0.0),
1460            );
1461            close(a.distance(b), 2.0 * r, 1e-14, "neighbours touch");
1462        }
1463        // Six tubes are as wide as the body; four much wider, eight narrower.
1464        close(TubeFinSet::closing_radius_m(body, 6), body, 1e-15, "six");
1465        close(
1466            TubeFinSet::closing_radius_m(body, 4),
1467            body * (1.0 + 2f64.sqrt()),
1468            1e-15,
1469            "four",
1470        );
1471        // No finite ring closes with one or two: the body's radius, as OpenRocket 24.12 gives.
1472        assert_eq!(TubeFinSet::closing_radius_m(body, 1), body);
1473        assert_eq!(TubeFinSet::closing_radius_m(body, 2), body);
1474    }
1475
1476    #[test]
1477    fn tube_fins_are_hollow_cylinders_around_the_body() {
1478        let tubes = TubeFinSet {
1479            count: 6,
1480            length_m: 0.1,
1481            outer_radius_m: 0.012,
1482            thickness_m: 0.001,
1483            base_angle_rad: 0.0,
1484            material: Material::bulk("cardboard", 790.0),
1485        };
1486        let rb = 0.02;
1487        let g = tubes.mass_properties(rb).unwrap();
1488        let (ro, ri) = (0.012, 0.011);
1489        let m1 = 790.0 * PI * (ro * ro - ri * ri) * 0.1;
1490        close(g.mass_kg, 6.0 * m1, 1e-13, "mass");
1491        let d = rb + ro;
1492        let axial = 6.0 * (0.5 * m1 * (ro * ro + ri * ri) + m1 * d * d);
1493        close(g.inertia_kg_m2.z_axis.z, axial, 1e-13, "axial");
1494        let transverse = 6.0 * m1 * ((ro * ro + ri * ri) / 4.0 + 0.01 / 12.0) + 3.0 * m1 * d * d;
1495        close(g.inertia_kg_m2.x_axis.x, transverse, 1e-12, "transverse");
1496        close(g.cg_m.z, -0.05, 1e-15, "center");
1497    }
1498
1499    /// OpenRocket's *Pods--airframes and winglets* example's cockpit: one balsa fin, 6.985 mm
1500    /// thick, a triangle on its tangent ogive's last 50 mm, its root through `pieces` straight
1501    /// pieces on the surface. Returns the set and its root leading edge's station on the nose.
1502    fn cockpit(pieces: u32) -> (FinSet, Profile, f64) {
1503        let nose = Profile::nose(
1504            crate::NoseShape::Ogive { radius_ratio: 1.0 },
1505            0.136525,
1506            0.0168275,
1507        )
1508        .unwrap();
1509        let (chord, fore) = (0.05, 0.136525 - 0.05);
1510        let base = nose.radius_m(fore);
1511        let rise = |x: f64| nose.radius_m(fore + x) - base;
1512        let root_m = (1..pieces)
1513            .rev()
1514            .map(|i| {
1515                let x = chord * f64::from(i) / f64::from(pieces);
1516                [x, rise(x)]
1517            })
1518            .collect();
1519        let planform = FinPlanform::Freeform {
1520            points_m: vec![
1521                [0.0, 0.0],
1522                [0.009347826086956524, 0.006956521739130436],
1523                [chord, 0.0022276567072510058],
1524            ],
1525            root_m,
1526        };
1527        let mut set = set(1, planform, 0.006985);
1528        set.material = Material::bulk("balsa", 170.0);
1529        (set, nose, fore)
1530    }
1531
1532    /// M4.5g4 (ADR-166): a fin's root along a nose cone is read as OpenRocket 24.12 reads it.
1533    /// OpenRocket draws the cockpit's root through 21 points, 2.5 mm apart: through the same
1534    /// points, hpr's area, mass and center of mass are OpenRocket's (its `getPlanformArea`,
1535    /// `getMass` and `getComponentCG`, to the six digits the oracle printed). hpr's own 64
1536    /// pieces sit closer to the curve, which bulges out of each chord: 2.9e-4 less area than
1537    /// OpenRocket's 20, and 3.1e-5 more than the curve itself.
1538    #[test]
1539    fn a_root_along_a_nose_cone_is_openrockets() {
1540        let (set, nose, fore) = cockpit(20);
1541        let radius = set.root_radius_on(&nose, fore).unwrap();
1542        close(
1543            radius,
1544            0.0145998,
1545            1e-5,
1546            "body radius at the root leading edge",
1547        );
1548        let g = set.planform.geometry().unwrap();
1549        close(g.area_m2, 1.449544e-4, 1e-6, "OpenRocket's planform area");
1550        let m = set.mass_properties(radius).unwrap();
1551        close(m.mass_kg, 1.72126e-4, 1e-5, "OpenRocket's mass");
1552        // Fin 0 at roll 0: its points at (r, τ, −x) about the root leading edge on the axis.
1553        close(
1554            -m.cg_m.z,
1555            0.0191163,
1556            1e-5,
1557            "OpenRocket's center of mass, aft",
1558        );
1559        close(m.cg_m.x, 0.017882, 1e-5, "OpenRocket's center of mass, out");
1560        let fine = |pieces| cockpit(pieces).0.planform.geometry().unwrap().area_m2;
1561        let (ours, curve) = (fine(64), fine(1024));
1562        assert!(
1563            ours < g.area_m2 && curve < ours,
1564            "{curve} < {ours} < {}",
1565            g.area_m2
1566        );
1567        close(ours, g.area_m2, 3.0e-4, "64 pieces against OpenRocket's 20");
1568        close(ours, curve, 3.5e-5, "64 pieces against the curve");
1569        // The slivers ROOT_SLIVER_SHARE's doc quotes, and the chord refused.
1570        let share = |pieces| {
1571            let (set, nose, fore) = cockpit(pieces);
1572            set.root_on_surface(&nose, fore).1 / set.planform.geometry().unwrap().area_m2
1573        };
1574        for (pieces, want) in [(20, 3.2e-4), (64, 3.1e-5), (1, 0.114)] {
1575            close(share(pieces), want, 0.1, "sliver share");
1576        }
1577        let (chord, nose, fore) = cockpit(1);
1578        let err = chord.root_radius_on(&nose, fore).unwrap_err();
1579        assert!(
1580            matches!(&err, DesignError::Geometry(m) if m.contains("cuts across")),
1581            "{err}"
1582        );
1583        // The span is the outline's highest point above the root leading edge.
1584        close(set.planform.span_m(), 0.006956521739130436, 1e-15, "span");
1585    }
1586
1587    /// M4.5g4 (ADR-166): a fin set sits on a nose cone or a transition only with its root on the
1588    /// surface, within a micron, along the body and with no tab or fillet; each refusal is named.
1589    #[test]
1590    fn a_root_off_the_surface_is_refused_by_name() {
1591        // A straight cone's surface is straight: an outline that ends on it needs no root points.
1592        let cone =
1593            Profile::transition(crate::NoseShape::Conical {}, 0.2, 0.02, 0.04, false).unwrap();
1594        let rise = 0.02 / 0.2 * 0.05;
1595        let on_cone = |end_h: f64| {
1596            set(
1597                3,
1598                FinPlanform::Freeform {
1599                    points_m: vec![[0.0, 0.0], [0.03, 0.04], [0.05, end_h]],
1600                    root_m: Vec::new(),
1601                },
1602                0.003,
1603            )
1604        };
1605        close(
1606            on_cone(rise).root_radius_on(&cone, 0.1).unwrap(),
1607            0.03,
1608            1e-14,
1609            "radius at x = 0.1 m",
1610        );
1611        let says = |set: FinSet, fore: f64, what: &str| {
1612            let err = set.root_radius_on(&cone, fore).unwrap_err();
1613            assert!(
1614                matches!(&err, DesignError::Geometry(m) if m.contains(what)),
1615                "{what}: {err}"
1616            );
1617        };
1618        // Just inside and just outside the micron.
1619        assert!(on_cone(rise + 0.9e-6).root_radius_on(&cone, 0.1).is_ok());
1620        says(on_cone(rise + 1.1e-6), 0.1, "stands");
1621        says(on_cone(rise - 1.1e-6), 0.1, "stands");
1622        // A level root on a cone stands off it at its trailing edge.
1623        says(set(3, rectangle(0.05, 0.04), 0.003), 0.1, "stands");
1624        // Along the body, a micron's slack at either end.
1625        says(on_cone(rise), -2e-6, "must stay on it");
1626        says(on_cone(rise), 0.15 + 2e-6, "must stay on it");
1627        assert!(on_cone(rise).root_radius_on(&cone, 0.15).is_ok());
1628        let mut tabbed = on_cone(rise);
1629        tabbed.tab = Some(FinTab {
1630            height_m: 0.005,
1631            length_m: 0.02,
1632            offset_m: 0.01,
1633        });
1634        says(tabbed, 0.1, "tab or a fillet");
1635        let mut filleted = on_cone(rise);
1636        filleted.fillet = Some(FinFillet {
1637            radius_m: 0.005,
1638            material: ply(),
1639        });
1640        says(filleted, 0.1, "tab or a fillet");
1641    }
1642
1643    #[test]
1644    fn bad_outlines_and_dimensions_are_rejected() {
1645        let bowtie = FinPlanform::Freeform {
1646            points_m: vec![[0.0, 0.0], [0.1, 0.1], [0.0, 0.1], [0.1, 0.0]],
1647            root_m: Vec::new(),
1648        };
1649        assert!(matches!(bowtie.validate(), Err(DesignError::Geometry(_))));
1650        let below = FinPlanform::Freeform {
1651            points_m: vec![[0.0, 0.0], [0.05, -0.01], [0.1, 0.0]],
1652            root_m: Vec::new(),
1653        };
1654        assert!(below.validate().is_err());
1655        // An outline that ends above the root leading edge has a root that rises: a planform, but
1656        // not one a body tube holds (ADR-166); the tree refuses it there (`tree.rs`'s tests).
1657        let open = FinPlanform::Freeform {
1658            points_m: vec![[0.0, 0.0], [0.05, 0.05], [0.1, 0.02]],
1659            root_m: Vec::new(),
1660        };
1661        assert!(open.validate().is_ok());
1662        let err = set(1, open, 0.003).check_level_root().unwrap_err();
1663        assert!(
1664            matches!(&err, DesignError::Geometry(m) if m.contains("needs a level root")),
1665            "{err}"
1666        );
1667        // Root points run fore, strictly between the root's ends, and stay at or above its
1668        // leading edge.
1669        let outline = vec![[0.0, 0.0], [0.05, 0.05], [0.1, 0.02]];
1670        let with_root = |root_m: Vec<[f64; 2]>| FinPlanform::Freeform {
1671            points_m: outline.clone(),
1672            root_m,
1673        };
1674        assert!(
1675            with_root(vec![[0.07, 0.012], [0.03, 0.004]])
1676                .validate()
1677                .is_ok()
1678        );
1679        for (root, says) in [
1680            (vec![[0.03, 0.004], [0.07, 0.012]], "must run fore"),
1681            (vec![[0.1, 0.02]], "must run fore"),
1682            (vec![[0.12, 0.02]], "must run fore"),
1683            (vec![[0.0, 0.0]], "must run fore"),
1684            (vec![[0.05, 0.06]], "runs below its root"),
1685            (vec![[0.07, 0.04], [0.03, 0.004]], "crosses itself"),
1686        ] {
1687            let err = with_root(root.clone()).validate().unwrap_err();
1688            assert!(
1689                matches!(&err, DesignError::Geometry(m) if m.contains(says)),
1690                "{root:?}: {err}"
1691            );
1692        }
1693        assert!(matches!(
1694            with_root(vec![[0.05, -0.001]]).validate(),
1695            Err(DesignError::Domain {
1696                what: "freeform fin span",
1697                ..
1698            })
1699        ));
1700        // The root leading edge is the origin, and the outline runs forward to aft.
1701        let shifted = FinPlanform::Freeform {
1702            points_m: vec![[0.03, 0.0], [0.08, 0.05], [0.13, 0.0]],
1703            root_m: Vec::new(),
1704        };
1705        assert!(shifted.validate().is_err());
1706        let backwards = FinPlanform::Freeform {
1707            points_m: vec![[0.0, 0.0], [-0.05, 0.05], [-0.1, 0.0]],
1708            root_m: Vec::new(),
1709        };
1710        assert!(backwards.validate().is_err());
1711        assert!(
1712            set(0, rectangle(0.1, 0.1), 0.003)
1713                .mass_properties(0.03)
1714                .is_err()
1715        );
1716        assert!(
1717            set(3, rectangle(0.1, 0.1), 0.0)
1718                .mass_properties(0.03)
1719                .is_err()
1720        );
1721        let mut fabric = set(3, rectangle(0.1, 0.1), 0.003);
1722        fabric.material = Material::surface("ripstop", 0.04);
1723        assert!(matches!(
1724            fabric.mass_properties(0.03),
1725            Err(DesignError::MaterialKind { .. })
1726        ));
1727    }
1728
1729    /// `[A, S_x, S_xx, S_yy]` of one fillet's section by quadrature, column by column: from the
1730    /// fillet circle's tangent point on the body out to the body's radius, the section runs from
1731    /// the body's circle up to the fillet's; beyond, from the fin's plane up to the fillet's. The
1732    /// body's circle is integrated in `x = R_b − v²`, which takes out its vertical tangent.
1733    fn fillet_section_by_quadrature(rb: f64, r: f64) -> [f64; 4] {
1734        let tol = Tolerance {
1735            relative: 1e-12,
1736            absolute: 1e-24,
1737            ..Tolerance::default()
1738        };
1739        let c = (rb * rb + 2.0 * rb * r).sqrt();
1740        let joint = |x: f64| r - (r * r - (x - c) * (x - c)).max(0.0).sqrt();
1741        let body = |x: f64| (rb * rb - x * x).max(0.0).sqrt();
1742        let moments = |bottom: f64, top: f64, x: f64| {
1743            [
1744                top - bottom,
1745                x * (top - bottom),
1746                x * x * (top - bottom),
1747                (top.powi(3) - bottom.powi(3)) / 3.0,
1748            ]
1749        };
1750        let near = integrate(
1751            |v| {
1752                let x = rb - v * v;
1753                moments(body(x), joint(x), x).map(|m| m * 2.0 * v)
1754            },
1755            0.0,
1756            (rb - c * rb / (rb + r)).sqrt(),
1757            tol,
1758        )
1759        .unwrap();
1760        let far = integrate(|x| moments(0.0, joint(x), x), rb, c, tol).unwrap();
1761        std::array::from_fn(|k| near.value[k] + far.value[k])
1762    }
1763
1764    #[test]
1765    fn fillet_section_is_its_region_by_quadrature() {
1766        for (rb, r) in [
1767            (0.05, 0.005),
1768            (0.05, 0.01),
1769            (0.05, 0.03),
1770            (0.1, 0.001),
1771            (0.02, 0.05),
1772        ] {
1773            let got = fillet_section(rb, r);
1774            let want = fillet_section_by_quadrature(rb, r);
1775            for (k, what) in ["A", "S_x", "S_xx", "S_yy"].into_iter().enumerate() {
1776                close(got[k], want[k], 1e-11, what);
1777            }
1778        }
1779        // No body, no fillet.
1780        assert_eq!(fillet_section(0.0, 0.01)[0], 0.0);
1781        // The series meets the difference where it hands over.
1782        let x: f64 = 0.25;
1783        close(
1784            span_less_sine(x.next_down()),
1785            x - x.sin(),
1786            1e-13,
1787            "x − sin x",
1788        );
1789        close(
1790            span_less_sine(1e-3),
1791            1e-9 / 6.0 - 1e-15 / 120.0 + 1e-21 / 5040.0,
1792            1e-15,
1793            "x − sin x",
1794        );
1795    }
1796
1797    #[test]
1798    fn a_fillet_on_a_flat_body_is_a_square_less_a_quarter_circle() {
1799        // The curvature's share is of order `r/R_b`, 1e-8 here.
1800        let r = 0.01;
1801        close(
1802            fillet_section(1e6, r)[0],
1803            r * r * (1.0 - PI / 4.0),
1804            1e-6,
1805            "flat",
1806        );
1807    }
1808
1809    /// The worked example in `docs/physics/mass.md`: a 5 mm fillet on a 30 mm tube is 4.253 mm²
1810    /// (the triangle 86.603 mm² less sectors of 64.506 and 17.843 mm²), and six of them along a
1811    /// 100 mm root in cardboard, 680 kg/m³, weigh 1.735 g.
1812    #[test]
1813    fn the_worked_fillet_example_is_the_docs() {
1814        let [a, ..] = fillet_section(0.030, 0.005);
1815        assert!((a * 1e6 - 4.253).abs() < 5e-4, "{}", a * 1e6);
1816        let planform = FinPlanform::Trapezoidal {
1817            root_chord_m: 0.1,
1818            tip_chord_m: 0.05,
1819            span_m: 0.05,
1820            sweep_m: 0.05,
1821        };
1822        let bare = set(3, planform.clone(), 0.003);
1823        let mut filleted = set(3, planform, 0.003);
1824        filleted.fillet = Some(FinFillet {
1825            radius_m: 0.005,
1826            material: Material::bulk("cardboard", 680.0),
1827        });
1828        let mass_g = (filleted.mass_properties(0.030).unwrap().mass_kg
1829            - bare.mass_properties(0.030).unwrap().mass_kg)
1830            * 1e3;
1831        assert!((mass_g - 1.735).abs() < 5e-4, "{mass_g}");
1832    }
1833
1834    /// Far from real fillets the section's closed form cancels away: a fillet more than
1835    /// [`FILLET_RATIO_MAX`] times the body radius is refused, not weighed on noise; one under a
1836    /// millionth of it, or on a body of no radius, weighs nothing.
1837    #[test]
1838    fn a_fillet_far_from_the_body_s_size_is_refused_or_none() {
1839        let planform = FinPlanform::Trapezoidal {
1840            root_chord_m: 0.1,
1841            tip_chord_m: 0.05,
1842            span_m: 0.05,
1843            sweep_m: 0.05,
1844        };
1845        let filleted = |radius_m: f64| {
1846            let mut fins = set(3, planform.clone(), 0.003);
1847            fins.fillet = Some(FinFillet {
1848                radius_m,
1849                material: Material::bulk("epoxy", 1200.0),
1850            });
1851            fins
1852        };
1853        let bare = set(3, planform.clone(), 0.003);
1854        let rb = 0.05;
1855        let widest = FILLET_RATIO_MAX * rb;
1856        for radius_m in [widest * (1.0 + 1e-12), 1e10, 1e12, 1e100] {
1857            let error = filleted(radius_m).single_fin(rb).unwrap_err();
1858            assert!(
1859                matches!(error, DesignError::Domain { what, value }
1860                    if what.starts_with("fin fillet radius") && value == radius_m / rb),
1861                "{radius_m}: {error:?}"
1862            );
1863        }
1864        let mass = |fins: &FinSet, rb: f64| fins.single_fin(rb).unwrap().mass_kg;
1865        assert!(mass(&filleted(widest), rb) > mass(&bare, rb));
1866        assert!(mass(&filleted(1e-6 * rb), rb) > mass(&bare, rb));
1867        for (radius_m, body_m) in [(1e-6 * rb * (1.0 - 1e-12), rb), (1e-20, rb), (0.005, 0.0)] {
1868            assert_eq!(
1869                mass(&filleted(radius_m), body_m),
1870                mass(&bare, body_m),
1871                "{radius_m} on {body_m}"
1872            );
1873        }
1874        // A fillet of a weightless material weighs nothing, and is no error.
1875        let mut fins = set(3, planform, 0.003);
1876        fins.fillet = Some(FinFillet {
1877            radius_m: 0.005,
1878            material: Material::bulk("air", 0.0),
1879        });
1880        assert_eq!(
1881            fins.single_fin(0.05).unwrap().mass_kg,
1882            set(3, fins.planform.clone(), 0.003)
1883                .single_fin(0.05)
1884                .unwrap()
1885                .mass_kg
1886        );
1887    }
1888
1889    #[test]
1890    fn fillets_are_a_prism_of_their_section_along_the_root() {
1891        // Moments by quadrature, assembled by the equations in `FinSet::fillets`' docs.
1892        let (rb, r, rho) = (0.05, 0.005, 1200.0);
1893        let planform = FinPlanform::Trapezoidal {
1894            root_chord_m: 0.1,
1895            tip_chord_m: 0.05,
1896            span_m: 0.05,
1897            sweep_m: 0.05,
1898        };
1899        let mut fins = set(3, planform, 0.003);
1900        let bare = fins.single_fin(rb).unwrap();
1901        fins.fillet = Some(FinFillet {
1902            radius_m: r,
1903            material: Material::bulk("epoxy", rho),
1904        });
1905        let with = fins.single_fin(rb).unwrap();
1906        let pair = with.without_part(&bare);
1907        let [a, sx, sxx, syy] = fillet_section_by_quadrature(rb, r);
1908        let l = 0.1;
1909        close(pair.mass_kg, 2.0 * rho * a * l, 1e-10, "mass");
1910        assert!((pair.cg_m - DVec3::new(sx / a, 0.0, -0.5 * l)).length() < 1e-12);
1911        let about_origin = with.inertia_about(DVec3::ZERO) - bare.inertia_about(DVec3::ZERO);
1912        let want = DMat3::from_cols(
1913            DVec3::new(
1914                2.0 * rho * (syy * l + a * l.powi(3) / 3.0),
1915                0.0,
1916                rho * sx * l * l,
1917            ),
1918            DVec3::new(0.0, 2.0 * rho * (sxx * l + a * l.powi(3) / 3.0), 0.0),
1919            DVec3::new(rho * sx * l * l, 0.0, 2.0 * rho * (sxx + syy) * l),
1920        );
1921        mat_close(about_origin, want, 1e-9, "fillets about the origin");
1922        // A fillet of no radius is none, and a negative one is refused.
1923        fins.fillet = Some(FinFillet {
1924            radius_m: 0.0,
1925            material: Material::bulk("epoxy", rho),
1926        });
1927        assert_eq!(fins.single_fin(rb).unwrap(), bare);
1928        fins.fillet = Some(FinFillet {
1929            radius_m: -0.001,
1930            material: Material::bulk("epoxy", rho),
1931        });
1932        assert!(matches!(
1933            fins.single_fin(rb),
1934            Err(DesignError::Domain {
1935                what: "fin fillet radius",
1936                ..
1937            })
1938        ));
1939    }
1940
1941    /// A fin set's count is 1 to [`FinSet::MAX_COUNT`]: none and one over are refused by the
1942    /// count check itself (its `what` and the count), so four billion fins are an error rather
1943    /// than a loop that never returns; the bound itself is weighed, as 64 fins of one fin each.
1944    #[test]
1945    fn a_fin_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1946        let rb = 0.028;
1947        let planform = FinPlanform::Trapezoidal {
1948            root_chord_m: 0.05,
1949            tip_chord_m: 0.02,
1950            span_m: 0.03,
1951            sweep_m: 0.02,
1952        };
1953        for count in [0, FinSet::MAX_COUNT + 1, 4_000_000_000, u32::MAX] {
1954            let fins = set(count, planform.clone(), 0.003);
1955            for result in [fins.validate(), fins.mass_properties(rb).map(|_| ())] {
1956                match result {
1957                    Err(DesignError::Domain { what, value }) => {
1958                        assert_eq!(what, "fin count (1 to 64)", "{count}");
1959                        assert_eq!(value, f64::from(count), "{count}");
1960                    }
1961                    other => panic!("{count} fins: {other:?}"),
1962                }
1963            }
1964        }
1965        let most = set(FinSet::MAX_COUNT, planform.clone(), 0.003);
1966        most.validate().unwrap();
1967        let one = set(1, planform, 0.003).mass_properties(rb).unwrap();
1968        let all = most.mass_properties(rb).unwrap();
1969        close(all.mass_kg, 64.0 * one.mass_kg, 1e-13, "64 fins' mass");
1970        // Evenly spaced, the fins' center lies on the axis.
1971        assert!(
1972            all.cg_m.x.abs() < 1e-15 && all.cg_m.y.abs() < 1e-15,
1973            "{:?}",
1974            all.cg_m
1975        );
1976    }
1977
1978    /// A tube fin set's count is 1 to [`TubeFinSet::MAX_COUNT`], refused by the count check
1979    /// itself above it, and weighed at it.
1980    #[test]
1981    fn a_tube_fin_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1982        let tubes = |count| TubeFinSet {
1983            count,
1984            length_m: 0.1,
1985            outer_radius_m: 0.012,
1986            thickness_m: 0.001,
1987            base_angle_rad: 0.0,
1988            material: Material::bulk("cardboard", 790.0),
1989        };
1990        for count in [0, TubeFinSet::MAX_COUNT + 1, 4_000_000_000, u32::MAX] {
1991            match tubes(count).mass_properties(0.02) {
1992                Err(DesignError::Domain { what, value }) => {
1993                    assert_eq!(what, "tube fin count (1 to 64)", "{count}");
1994                    assert_eq!(value, f64::from(count), "{count}");
1995                }
1996                other => panic!("{count} tubes: {other:?}"),
1997            }
1998        }
1999        let one = tubes(1).mass_properties(0.02).unwrap();
2000        let all = tubes(TubeFinSet::MAX_COUNT).mass_properties(0.02).unwrap();
2001        close(all.mass_kg, 64.0 * one.mass_kg, 1e-13, "64 tubes' mass");
2002    }
2003}