Skip to main content

hpr_design/
shapes.rs

1//! Nose cone and transition profiles: radius and slope along the axis for every shape.
2//!
3//! A profile gives the outer radius `r(x)` at distance `x` aft of its forward end, over
4//! `0 ≤ x ≤ L`. Every shape is defined by a normalized curve `g(ξ)` with `g(0) = 0` at the tip and
5//! `g(1) = 1` at the base, where `ξ` runs from tip to base. The formulas, with `R` the base radius
6//! and `L` the length, are from G. A. Crowell Sr., *The Descriptive Geometry of Nose Cones*, 1996
7//! (pp. 1–6), and S. Niskanen, *OpenRocket technical documentation* v13.05, 2013, appendix A
8//! (pp. 102–105):
9//!
10//! ```text
11//! conical            g = ξ
12//! ogive              y = √(ρ² − (Lξ − ρ cos α)²) + ρ sin α,   α = atan(R/L) − acos(√(L² + R²) / 2ρ)
13//! elliptical         g = √(1 − (1 − ξ)²)
14//! power series       g = ξⁿ,                            0.05 ≤ n ≤ 1
15//! parabolic series   g = (2ξ − K′ξ²) / (2 − K′),        0 ≤ K′ ≤ 1
16//! Haack series       g = √((θ − sin 2θ / 2 + C sin³θ) / π),   θ = acos(1 − 2ξ),   0 ≤ C ≤ 2/3
17//! ```
18//!
19//! The ogive is Crowell's secant ogive: a circular arc of radius `ρ` through the tip and the base
20//! rim. `ρ` is given as a multiple of the tangent-ogive radius `ρ_t = (R² + L²) / 2R`
21//! ([`NoseShape::Ogive::radius_ratio`]): 1 is the tangent ogive, larger values are secant ogives
22//! that meet the base at an angle, and values below 1 bulge beyond `R` before the base. The arc
23//! passes through the tip only while its center is not above the axis, which needs
24//! `ρ ≥ (L² + R²) / 2L`, that is `radius_ratio ≥ R/L`. A cone is the limit of infinite `ρ`.
25//! The Haack series is monotone for `C ≤ 2/3` (`d(g²)/dθ ∝ sin²θ (2 + 3C cos θ)`); `C = 0` is the
26//! LD-Haack (von Kármán) ogive and `C = 1/3` the LV-Haack.
27//!
28//! **Transitions** join a fore radius `R_f` to an aft radius `R_a` over length `L`. The shape's tip
29//! lies at the smaller end, so a transition that grows aft has `r = R_f + (R_a − R_f) g(x/L)` and
30//! one that shrinks aft (a boattail) is its mirror image, `r = R_a + (R_f − R_a) g(1 − x/L)`.
31//! A **clipped** transition instead takes a whole nose cone of base radius `max(R_f, R_a)` and
32//! length `L_n ≥ L`, and cuts it where its radius is `min(R_f, R_a)`; `L_n` is chosen so that the
33//! piece left is `L` long (OpenRocket technical documentation, §A.7, p. 105). A conical or
34//! tangent-ogive transition is the same clipped or not.
35//!
36//! See `docs/physics/shapes.md`.
37
38use std::f64::consts::PI;
39
40use serde::{Deserialize, Serialize};
41
42use crate::error::DesignError;
43
44/// The shape of a nose cone, or of a transition's profile.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
47#[non_exhaustive]
48pub enum NoseShape {
49    /// A straight cone.
50    Conical {},
51    /// A circular-arc ogive whose arc radius is `radius_ratio` times the tangent-ogive radius.
52    Ogive {
53        /// The arc radius over the tangent-ogive radius: 1 for a tangent ogive, above 1 for a
54        /// secant ogive, below 1 (down to `R/L`) for a bulged secant ogive.
55        radius_ratio: f64,
56    },
57    /// Half an ellipse: a blunt, rounded tip.
58    Elliptical {},
59    /// `g = ξⁿ`: `n = 1` is a cone and `n = ½` a paraboloid.
60    PowerSeries {
61        /// The exponent `n`, in `[0.05, 1]` ([`MIN_POWER_EXPONENT`]).
62        exponent: f64,
63    },
64    /// Parabolic series: `K′ = 0` is a cone and `K′ = 1` a full parabola, tangent at the base.
65    ParabolicSeries {
66        /// The parameter `K′`, in `[0, 1]`.
67        parameter: f64,
68    },
69    /// Haack series: `C = 0` is the von Kármán (LD-Haack) ogive and `C = 1/3` the LV-Haack.
70    Haack {
71        /// The parameter `C`, in `[0, 2/3]`.
72        parameter: f64,
73    },
74}
75
76/// The smallest power-series exponent accepted. Blunter profiles approach a flat face whose area the
77/// integrals can't resolve: at `n = 1e-9` a nose's wetted area misses the face's `πR²`, a nose fails
78/// to converge between `1e-8` and `0.01`, and an unclipped transition up to about `0.038`, where
79/// the surface integrand `∝ ξ^(2n−1)` drives bisection to subnormal stations. `0.05` is the bluntest
80/// checked, as a nose and as transitions both ways, against closed-form volumes. Model a flat face
81/// as a tube and a bulkhead.
82pub const MIN_POWER_EXPONENT: f64 = 0.05;
83
84impl NoseShape {
85    /// The tangent ogive.
86    pub const TANGENT_OGIVE: Self = Self::Ogive { radius_ratio: 1.0 };
87    /// The von Kármán (LD-Haack) ogive.
88    pub const VON_KARMAN: Self = Self::Haack { parameter: 0.0 };
89    /// The LV-Haack shape.
90    pub const LV_HAACK: Self = Self::Haack {
91        parameter: 1.0 / 3.0,
92    };
93
94    /// Checks the shape parameter's range. `fineness` is the length over the base radius of the
95    /// curve the shape is applied to (the ogive's lower bound on `radius_ratio` depends on it).
96    fn validate(&self, fineness: f64) -> Result<(), DesignError> {
97        let (what, value, ok) = match *self {
98            Self::Conical {} | Self::Elliptical {} => return Ok(()),
99            Self::Ogive { radius_ratio } => (
100                "ogive radius ratio",
101                radius_ratio,
102                // A small slack keeps the boundary shape (the arc center at the tip) valid.
103                radius_ratio.is_finite() && radius_ratio * fineness >= 1.0 - 1e-12,
104            ),
105            Self::PowerSeries { exponent } => (
106                "power series exponent",
107                exponent,
108                (MIN_POWER_EXPONENT..=1.0).contains(&exponent),
109            ),
110            Self::ParabolicSeries { parameter } => (
111                "parabolic series parameter",
112                parameter,
113                (0.0..=1.0).contains(&parameter),
114            ),
115            Self::Haack { parameter } => (
116                "Haack series parameter",
117                parameter,
118                (0.0..=2.0 / 3.0).contains(&parameter),
119            ),
120        };
121        if ok {
122            Ok(())
123        } else {
124            Err(DesignError::Domain { what, value })
125        }
126    }
127}
128
129/// `θ − sin 2θ / 2`, by its Taylor series below `θ = 0.1`, where the difference cancels:
130/// `Σ_{k≥1} (−1)^(k+1) 2^(2k) θ^(2k+1) / (2k+1)!`. Five terms leave an error below `1e-16`
131/// relative there.
132fn haack_core(theta: f64) -> f64 {
133    if theta >= 0.1 {
134        return theta - (2.0 * theta).sin() / 2.0;
135    }
136    let t2 = theta * theta;
137    let mut term = theta;
138    let mut sum = 0.0;
139    for k in 1..=5 {
140        // term = 2^(2k) θ^(2k+1) / (2k+1)!, built up from the previous one.
141        let k2 = f64::from(2 * k);
142        term *= 4.0 * t2 / (k2 * (k2 + 1.0));
143        sum += if k % 2 == 1 { term } else { -term };
144    }
145    sum
146}
147
148/// A circular arc through `(0, 0)` and `(L, R)` in units of `R`: center `(xc, yc)`, radius `rho`.
149#[derive(Debug, Clone, Copy, PartialEq)]
150struct Arc {
151    xc: f64,
152    yc: f64,
153    rho: f64,
154}
155
156impl Arc {
157    /// Crowell's secant ogive for fineness `lambda = L/R` and `rho = ratio · ρ_t` (units of `R`).
158    /// The center lies on the chord's perpendicular bisector, on the side away from the profile,
159    /// at `d = √(ρ² − c²/4)` from the chord's midpoint (chord length `c = √(λ² + 1)`), which is the
160    /// center `(ρ cos α, ρ sin α)` of Crowell's formula computed without rounding `α` near `−π/2`.
161    fn new(lambda: f64, ratio: f64) -> Self {
162        let chord = (lambda * lambda + 1.0).sqrt();
163        let rho = ratio * 0.5 * (lambda * lambda + 1.0);
164        let half = 0.5 * chord;
165        let d = ((rho - half).max(0.0) * (rho + half)).sqrt();
166        Self {
167            xc: 0.5 * lambda + d / chord,
168            // Rounding can leave the boundary shape's center a hair above the axis.
169            yc: (0.5 - d * lambda / chord).min(0.0),
170            rho,
171        }
172    }
173
174    /// Height and slope at `x` (units of `R`). The arc passes through the origin, so
175    /// `ρ² = x_c² + y_c²` and `ρ² − (x − x_c)² = y_c² + x (2x_c − x)`, a sum with no cancellation
176    /// even when `ρ` is huge; and `y (y − 2y_c) = x (2x_c − x)` gives
177    /// `y = x (2x_c − x) / (root − y_c)` without the cancellation in `root + y_c` near the tip.
178    fn eval(&self, x: f64) -> (f64, f64) {
179        let chord_term = x * (2.0 * self.xc - x);
180        let root = (self.yc * self.yc + chord_term).max(0.0).sqrt();
181        let denominator = root - self.yc;
182        let y = if denominator > 0.0 {
183            chord_term / denominator
184        } else {
185            0.0
186        };
187        (y, (self.xc - x) / root)
188    }
189}
190
191/// The normalized curve of a shape at a given fineness.
192#[derive(Debug, Clone, Copy, PartialEq)]
193enum Curve {
194    Conical,
195    Arc { arc: Arc, lambda: f64 },
196    Elliptical,
197    Power(f64),
198    Parabolic(f64),
199    Haack(f64),
200}
201
202impl Curve {
203    fn new(shape: NoseShape, fineness: f64) -> Self {
204        match shape {
205            NoseShape::Conical {} => Self::Conical,
206            NoseShape::Ogive { radius_ratio } => Self::Arc {
207                arc: Arc::new(fineness, radius_ratio),
208                lambda: fineness,
209            },
210            NoseShape::Elliptical {} => Self::Elliptical,
211            NoseShape::PowerSeries { exponent } => Self::Power(exponent),
212            NoseShape::ParabolicSeries { parameter } => Self::Parabolic(parameter),
213            NoseShape::Haack { parameter } => Self::Haack(parameter),
214        }
215    }
216
217    /// `(g(ξ), dg/dξ)` for `ξ` in `[0, 1]`. The slope is infinite at a blunt tip.
218    fn eval(&self, xi: f64) -> (f64, f64) {
219        let xi = xi.clamp(0.0, 1.0);
220        match *self {
221            Self::Conical => (xi, 1.0),
222            Self::Arc { arc, lambda } => {
223                let (y, slope) = arc.eval(lambda * xi);
224                (y.max(0.0), slope * lambda)
225            }
226            Self::Elliptical => {
227                let g = (xi * (2.0 - xi)).sqrt();
228                (g, (1.0 - xi) / g)
229            }
230            Self::Power(n) => (xi.powf(n), n * xi.powf(n - 1.0)),
231            Self::Parabolic(k) => (
232                (2.0 * xi - k * xi * xi) / (2.0 - k),
233                (2.0 - 2.0 * k * xi) / (2.0 - k),
234            ),
235            Self::Haack(c) => {
236                // θ = acos(1 − 2ξ) = 2 asin(√ξ); the second form keeps θ exact near the tip.
237                let theta = 2.0 * xi.sqrt().asin();
238                let (sin, cos) = (theta.sin(), theta.cos());
239                let g2 = (haack_core(theta) + c * sin * sin * sin) / PI;
240                let g = g2.max(0.0).sqrt();
241                if g == 0.0 {
242                    return (0.0, f64::INFINITY);
243                }
244                // dg/dξ = sin θ (2 + 3C cos θ) / (π g), from d(g²)/dθ and dθ/dξ = 2 / sin θ.
245                (g, sin * (2.0 + 3.0 * c * cos) / (PI * g))
246            }
247        }
248    }
249}
250
251/// An axisymmetric profile: a nose cone (fore radius zero) or a transition, as radius against
252/// distance aft of its forward end.
253#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
254#[serde(try_from = "ProfileData", into = "ProfileData")]
255pub struct Profile {
256    shape: NoseShape,
257    length_m: f64,
258    fore_radius_m: f64,
259    aft_radius_m: f64,
260    clipped: bool,
261    /// The curve and where it sits: `r = offset + scale · g(ξ)`.
262    curve: Curve,
263    /// Radius at the shape's tip end, m.
264    offset_m: f64,
265    /// Radius span of the curve, m.
266    scale_m: f64,
267    /// Whether the shape's tip is at the aft end (the profile shrinks aft).
268    tip_aft: bool,
269    /// For a clipped profile, the fraction of the whole nose cut away at the tip: `ξ` runs over
270    /// `[xi0, 1]` along the piece.
271    xi0: f64,
272}
273
274/// The serialized form of a [`Profile`].
275#[derive(Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct ProfileData {
278    shape: NoseShape,
279    length_m: f64,
280    fore_radius_m: f64,
281    aft_radius_m: f64,
282    #[serde(default)]
283    clipped: bool,
284}
285
286impl TryFrom<ProfileData> for Profile {
287    type Error = DesignError;
288
289    fn try_from(data: ProfileData) -> Result<Self, DesignError> {
290        Profile::transition(
291            data.shape,
292            data.length_m,
293            data.fore_radius_m,
294            data.aft_radius_m,
295            data.clipped,
296        )
297    }
298}
299
300impl From<Profile> for ProfileData {
301    fn from(profile: Profile) -> Self {
302        Self {
303            shape: profile.shape,
304            length_m: profile.length_m,
305            fore_radius_m: profile.fore_radius_m,
306            aft_radius_m: profile.aft_radius_m,
307            clipped: profile.clipped,
308        }
309    }
310}
311
312/// Checks that a dimension is finite and positive (or non-negative when `allow_zero`).
313pub(crate) fn check_dimension(
314    what: &'static str,
315    value: f64,
316    allow_zero: bool,
317) -> Result<(), DesignError> {
318    let ok = value.is_finite() && (value > 0.0 || (allow_zero && value == 0.0));
319    if ok {
320        Ok(())
321    } else {
322        Err(DesignError::Domain { what, value })
323    }
324}
325
326impl Profile {
327    /// A nose cone of length `length_m` and base radius `base_radius_m`, tip forward.
328    ///
329    /// # Errors
330    ///
331    /// [`DesignError::Domain`] for a non-positive or non-finite dimension or a shape parameter out
332    /// of range.
333    pub fn nose(shape: NoseShape, length_m: f64, base_radius_m: f64) -> Result<Self, DesignError> {
334        check_dimension("nose cone base radius", base_radius_m, false)?;
335        Self::transition(shape, length_m, 0.0, base_radius_m, false)
336    }
337
338    /// A transition from `fore_radius_m` to `aft_radius_m` over `length_m`; see the module docs
339    /// for how the shape is oriented and what `clipped` means. Equal radii give a cylinder.
340    ///
341    /// # Errors
342    ///
343    /// [`DesignError::Domain`] for a non-positive length, a negative or non-finite radius, both
344    /// radii zero, or a shape parameter out of range.
345    pub fn transition(
346        shape: NoseShape,
347        length_m: f64,
348        fore_radius_m: f64,
349        aft_radius_m: f64,
350        clipped: bool,
351    ) -> Result<Self, DesignError> {
352        check_dimension("profile length", length_m, false)?;
353        check_dimension("profile fore radius", fore_radius_m, true)?;
354        check_dimension("profile aft radius", aft_radius_m, true)?;
355        let big = fore_radius_m.max(aft_radius_m);
356        let small = fore_radius_m.min(aft_radius_m);
357        check_dimension("profile largest radius", big, false)?;
358        let tip_aft = aft_radius_m < fore_radius_m;
359        let mut profile = Self {
360            shape,
361            length_m,
362            fore_radius_m,
363            aft_radius_m,
364            clipped,
365            curve: Curve::Conical,
366            offset_m: small,
367            scale_m: big - small,
368            tip_aft,
369            xi0: 0.0,
370        };
371        if big == small {
372            // A cylinder: the curve's scale is zero, so its shape doesn't matter.
373            return Ok(profile);
374        }
375        if clipped && small > 0.0 {
376            let (curve, nose_length) = clip(shape, length_m, small, big)?;
377            profile.curve = curve;
378            profile.offset_m = 0.0;
379            profile.scale_m = big;
380            profile.xi0 = 1.0 - length_m / nose_length;
381        } else {
382            let fineness = length_m / (big - small);
383            shape.validate(fineness)?;
384            profile.curve = Curve::new(shape, fineness);
385        }
386        Ok(profile)
387    }
388
389    /// The shape.
390    pub fn shape(&self) -> NoseShape {
391        self.shape
392    }
393
394    /// Length, m.
395    pub fn length_m(&self) -> f64 {
396        self.length_m
397    }
398
399    /// Radius at the forward end, m.
400    pub fn fore_radius_m(&self) -> f64 {
401        self.fore_radius_m
402    }
403
404    /// Radius at the aft end, m.
405    pub fn aft_radius_m(&self) -> f64 {
406        self.aft_radius_m
407    }
408
409    /// Whether the profile is cut from a longer nose cone.
410    pub fn clipped(&self) -> bool {
411        self.clipped
412    }
413
414    /// The largest radius anywhere on the profile, m (above both end radii for a bulged ogive).
415    pub fn max_radius_m(&self) -> f64 {
416        let ends = self.fore_radius_m.max(self.aft_radius_m);
417        match self.curve {
418            Curve::Arc { arc, lambda } if self.scale_m > 0.0 => {
419                // The arc peaks at its center's station when that lies inside the curve.
420                let xi_peak = arc.xc / lambda;
421                let lo = self.xi0;
422                if xi_peak > lo && xi_peak < 1.0 {
423                    ends.max(self.offset_m + self.scale_m * (arc.rho + arc.yc))
424                } else {
425                    ends
426                }
427            }
428            _ => ends,
429        }
430    }
431
432    /// `ξ` along the curve at `distance_m` from the forward end (or the aft end when `from_aft`),
433    /// and `dξ/dx`. Measuring from the end nearer the tip keeps `ξ` exact where a blunt tip's
434    /// slope blows up.
435    fn xi(&self, distance_m: f64, from_aft: bool) -> (f64, f64) {
436        let fraction = (distance_m / self.length_m).clamp(0.0, 1.0);
437        let span = 1.0 - self.xi0;
438        let toward_tip = if from_aft == self.tip_aft {
439            fraction
440        } else {
441            1.0 - fraction
442        };
443        let sign = if self.tip_aft { -1.0 } else { 1.0 };
444        (self.xi0 + span * toward_tip, sign * span / self.length_m)
445    }
446
447    /// Radius at `x_m` aft of the forward end, m; `x` is clamped to `[0, L]`.
448    pub fn radius_m(&self, x_m: f64) -> f64 {
449        self.radius_and_slope(x_m).0
450    }
451
452    /// Radius and slope `dr/dx` at `x_m`; the slope is infinite at a blunt tip.
453    pub fn radius_and_slope(&self, x_m: f64) -> (f64, f64) {
454        self.at_distance(x_m, false)
455    }
456
457    /// Radius and slope `dr/dx` at `distance_m` from the forward end, or from the aft end when
458    /// `from_aft`, computed without rounding the distance through `L − x`.
459    pub(crate) fn at_distance(&self, distance_m: f64, from_aft: bool) -> (f64, f64) {
460        if self.scale_m == 0.0 {
461            return (self.offset_m, 0.0);
462        }
463        let (xi, dxi) = self.xi(distance_m, from_aft);
464        let (g, dg) = self.curve.eval(xi);
465        (self.offset_m + self.scale_m * g, self.scale_m * dg * dxi)
466    }
467}
468
469/// For a clipped transition, the whole nose cone's curve and length: the nose of base radius
470/// `big` whose radius falls to `small` at `length` from its base.
471fn clip(shape: NoseShape, length: f64, small: f64, big: f64) -> Result<(Curve, f64), DesignError> {
472    let target = small / big;
473    // Shapes other than the ogive have a curve independent of fineness: invert g directly.
474    let invert = |curve: Curve| -> f64 {
475        let (mut lo, mut hi) = (0.0, 1.0);
476        for _ in 0..200 {
477            let mid = 0.5 * (lo + hi);
478            if curve.eval(mid).0 < target {
479                lo = mid;
480            } else {
481                hi = mid;
482            }
483            if hi - lo <= f64::EPSILON {
484                break;
485            }
486        }
487        0.5 * (lo + hi)
488    };
489    match shape {
490        NoseShape::Ogive { radius_ratio } if radius_ratio < 1.0 => {
491            Err(DesignError::Geometry(format!(
492                "a clipped ogive transition needs a monotone profile, so its radius ratio must be at \
493             least 1, not {radius_ratio}"
494            )))
495        }
496        NoseShape::Ogive { radius_ratio } => {
497            // The whole nose's fineness sets its arc; find the nose length whose cut piece is
498            // `length` long. The cut fraction grows with the nose length, so bisect on it.
499            let piece = |nose_length: f64| -> Result<f64, DesignError> {
500                let fineness = nose_length / big;
501                shape.validate(fineness)?;
502                let xi0 = invert(Curve::new(shape, fineness));
503                Ok(nose_length * (1.0 - xi0))
504            };
505            let mut lo = length;
506            let mut hi = length;
507            // Grow the bracket until the piece is at least `length` long.
508            let mut grown = false;
509            for _ in 0..200 {
510                hi *= 2.0;
511                if piece(hi).is_ok_and(|p| p >= length) {
512                    grown = true;
513                    break;
514                }
515            }
516            if !grown {
517                return Err(DesignError::Domain {
518                    what: "ogive radius ratio",
519                    value: radius_ratio,
520                });
521            }
522            for _ in 0..200 {
523                let mid = 0.5 * (lo + hi);
524                match piece(mid) {
525                    Ok(p) if p >= length => hi = mid,
526                    _ => lo = mid,
527                }
528                if hi - lo <= 4.0 * f64::EPSILON * hi {
529                    break;
530                }
531            }
532            let fineness = hi / big;
533            shape.validate(fineness)?;
534            Ok((Curve::new(shape, fineness), hi))
535        }
536        _ => {
537            shape.validate(1.0)?;
538            let curve = Curve::new(shape, 1.0);
539            let xi0 = invert(curve);
540            Ok((curve, length / (1.0 - xi0)))
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    const ALL: [NoseShape; 9] = [
550        NoseShape::Conical {},
551        NoseShape::TANGENT_OGIVE,
552        NoseShape::Ogive { radius_ratio: 2.5 },
553        NoseShape::Ogive { radius_ratio: 0.6 },
554        NoseShape::Elliptical {},
555        NoseShape::PowerSeries { exponent: 0.5 },
556        NoseShape::ParabolicSeries { parameter: 0.75 },
557        NoseShape::VON_KARMAN,
558        NoseShape::LV_HAACK,
559    ];
560
561    #[test]
562    fn every_nose_runs_from_the_tip_to_the_base_radius() {
563        for shape in ALL {
564            let nose = Profile::nose(shape, 0.3, 0.05).unwrap();
565            assert!(nose.radius_m(0.0).abs() < 1e-15, "{shape:?}");
566            assert!((nose.radius_m(0.3) - 0.05).abs() < 1e-15, "{shape:?}");
567            // The slope matches a central difference inside the profile.
568            for x in [0.03, 0.11, 0.2, 0.29] {
569                let h = 1e-6;
570                let numeric = (nose.radius_m(x + h) - nose.radius_m(x - h)) / (2.0 * h);
571                let (_, slope) = nose.radius_and_slope(x);
572                assert!(
573                    (numeric - slope).abs() < 1e-7 * slope.abs().max(1.0),
574                    "{shape:?} at {x}: {numeric} vs {slope}"
575                );
576            }
577        }
578    }
579
580    /// Loft lesson L48: Loft had only the tangent ogive, a silent power-series default, and Haack
581    /// names swapped in a comment.
582    #[test]
583    fn secant_ogive_and_haack_parameters_change_profile() {
584        let (length, radius) = (0.4, 0.05);
585        let at =
586            |shape: NoseShape, x: f64| Profile::nose(shape, length, radius).unwrap().radius_m(x);
587        // The tangent ogive meets the base with zero slope; a secant ogive doesn't.
588        let tangent = Profile::nose(NoseShape::TANGENT_OGIVE, length, radius).unwrap();
589        assert!(tangent.radius_and_slope(length).1.abs() < 1e-12);
590        let secant = Profile::nose(NoseShape::Ogive { radius_ratio: 2.0 }, length, radius).unwrap();
591        assert!(secant.radius_and_slope(length).1 > 0.01);
592        // A secant ogive lies between the cone and the tangent ogive; a bulged one exceeds R.
593        let x = 0.2;
594        assert!(at(NoseShape::Conical {}, x) < at(NoseShape::Ogive { radius_ratio: 2.0 }, x));
595        assert!(at(NoseShape::Ogive { radius_ratio: 2.0 }, x) < at(NoseShape::TANGENT_OGIVE, x));
596        let bulged = Profile::nose(NoseShape::Ogive { radius_ratio: 0.5 }, length, radius).unwrap();
597        assert!(bulged.max_radius_m() > radius);
598        assert!(bulged.radius_m(0.35) > radius);
599        // An enormous ogive radius approaches the cone.
600        let flat = at(NoseShape::Ogive { radius_ratio: 1e8 }, x);
601        assert!((flat - at(NoseShape::Conical {}, x)).abs() < 1e-8);
602        // Haack C = 1/3 (LV) is fuller than C = 0 (von Kármán) everywhere inside, and the tips
603        // are the published closed forms: at ξ = ½, θ = π/2 and g² = (π/2 + C)/π.
604        let mid = length / 2.0;
605        let vk = at(NoseShape::VON_KARMAN, mid);
606        let lv = at(NoseShape::LV_HAACK, mid);
607        assert!((vk - radius * 0.5f64.sqrt()).abs() < 1e-15);
608        assert!((lv - radius * ((PI / 2.0 + 1.0 / 3.0) / PI).sqrt()).abs() < 1e-15);
609        assert!(lv > vk);
610        // Out-of-range parameters are errors, not silent defaults.
611        for shape in [
612            NoseShape::PowerSeries { exponent: 0.0 },
613            NoseShape::PowerSeries { exponent: 1.5 },
614            NoseShape::PowerSeries { exponent: 0.049 },
615            NoseShape::ParabolicSeries { parameter: -0.1 },
616            NoseShape::Haack { parameter: 0.7 },
617            NoseShape::Ogive { radius_ratio: 0.1 },
618            NoseShape::Ogive {
619                radius_ratio: f64::NAN,
620            },
621        ] {
622            assert!(Profile::nose(shape, length, radius).is_err(), "{shape:?}");
623        }
624    }
625
626    /// Loft lesson L49: Loft's transitions used a "clipped nose" profile with a kink and never
627    /// checked it against what `.ork` files mean.
628    #[test]
629    fn ogive_transition_hits_both_radii_and_is_monotone() {
630        for shape in ALL {
631            if matches!(shape, NoseShape::Ogive { radius_ratio } if radius_ratio < 1.0) {
632                continue; // a bulged ogive is deliberately not monotone
633            }
634            for clipped in [false, true] {
635                for (fore, aft) in [(0.03, 0.05), (0.05, 0.03)] {
636                    let t = Profile::transition(shape, 0.1, fore, aft, clipped).unwrap();
637                    assert!(
638                        (t.radius_m(0.0) - fore).abs() < 1e-12,
639                        "{shape:?} {clipped}"
640                    );
641                    assert!((t.radius_m(0.1) - aft).abs() < 1e-12, "{shape:?} {clipped}");
642                    let mut last = t.radius_m(0.0);
643                    for i in 1..=1000 {
644                        let r = t.radius_m(0.1 * f64::from(i) / 1000.0);
645                        if fore < aft {
646                            assert!(r >= last - 1e-15, "{shape:?} {clipped} grows");
647                        } else {
648                            assert!(r <= last + 1e-15, "{shape:?} {clipped} shrinks");
649                        }
650                        last = r;
651                    }
652                }
653            }
654        }
655        // A boattail is the mirror image of the growing transition.
656        let grow = Profile::transition(NoseShape::TANGENT_OGIVE, 0.1, 0.03, 0.05, false).unwrap();
657        let shrink = Profile::transition(NoseShape::TANGENT_OGIVE, 0.1, 0.05, 0.03, false).unwrap();
658        for x in [0.0, 0.013, 0.05, 0.08] {
659            assert!((grow.radius_m(x) - shrink.radius_m(0.1 - x)).abs() < 1e-15);
660        }
661        // Clipped and unclipped agree for the cone and the tangent ogive (techdoc §A.7) and differ
662        // for a power series.
663        for shape in [NoseShape::Conical {}, NoseShape::TANGENT_OGIVE] {
664            let a = Profile::transition(shape, 0.1, 0.03, 0.05, false).unwrap();
665            let b = Profile::transition(shape, 0.1, 0.03, 0.05, true).unwrap();
666            for x in [0.01, 0.04, 0.07] {
667                assert!(
668                    (a.radius_m(x) - b.radius_m(x)).abs() < 1e-12,
669                    "{shape:?} at {x}"
670                );
671            }
672        }
673        let shape = NoseShape::PowerSeries { exponent: 0.5 };
674        let a = Profile::transition(shape, 0.1, 0.03, 0.05, false).unwrap();
675        let b = Profile::transition(shape, 0.1, 0.03, 0.05, true).unwrap();
676        assert!((a.radius_m(0.02) - b.radius_m(0.02)).abs() > 1e-3);
677        // A clipped power-series transition is a piece of the whole nose: r = R (x_n / L_n)^n.
678        let n_len = 0.1 / (1.0 - (0.03f64 / 0.05).powi(2));
679        let x0 = n_len - 0.1;
680        assert!((b.radius_m(0.02) - 0.05 * ((x0 + 0.02) / n_len).sqrt()).abs() < 1e-12);
681    }
682
683    fn close(got: f64, want: f64, rel: f64, what: &str) {
684        let err = ((got - want) / want).abs();
685        assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
686    }
687
688    /// Loft lesson L91: Loft's nose-volume tests (cone πR²L/3; tangent ogive R 0.04 m, L 0.25 m
689    /// gives 6.7509e-4 m³; Haack πR²L(1/2 + 3C/16)) are worth keeping. Here every shape's filled
690    /// volume and centroid, and every closed-form wetted and planform area, is checked against its
691    /// closed form (Crowell 1996, pp. 12-14, with the corrections in `docs/physics/shapes.md`).
692    #[test]
693    fn nose_volumes_match_closed_forms() {
694        use crate::solids::{Wall, revolve};
695        let tol = 1e-10;
696        let (r, l): (f64, f64) = (0.05, 0.3);
697        let solid = |shape| revolve(&Profile::nose(shape, l, r).unwrap(), Wall::Filled {}).unwrap();
698
699        // Cone.
700        let g = solid(NoseShape::Conical {});
701        close(g.volume_m3, PI * r * r * l / 3.0, tol, "cone volume");
702        close(g.centroid_m, 0.75 * l, tol, "cone centroid");
703        close(
704            g.wetted_area_m2,
705            PI * r * (r * r + l * l).sqrt(),
706            tol,
707            "cone area",
708        );
709        close(g.planform_area_m2, r * l, tol, "cone planform");
710        close(
711            g.planform_centroid_m,
712            2.0 * l / 3.0,
713            tol,
714            "cone planform centroid",
715        );
716
717        // Loft's number for the tangent ogive.
718        let loft = revolve(
719            &Profile::nose(NoseShape::TANGENT_OGIVE, 0.25, 0.04).unwrap(),
720            Wall::Filled {},
721        )
722        .unwrap();
723        assert!((loft.volume_m3 - 6.7509e-4).abs() < 5e-9);
724
725        // Ogives, as arcs of radius ρ centered at (xc, yc): y = √(ρ² − (x − xc)²) + yc.
726        for ratio in [1.0, 2.5, 0.6] {
727            let lam = l / r;
728            let rho = ratio * (r * r + l * l) / (2.0 * r);
729            let alpha = (r / l).atan() - ((l * l + r * r).sqrt() / (2.0 * rho)).acos();
730            let (xc, yc) = (rho * alpha.cos(), rho * alpha.sin());
731            assert!(ratio * lam >= 1.0);
732            // F(u) = ∫ √(ρ² − u²) du and G(u) = ∫ u √(ρ² − u²) du.
733            let f = |u: f64| 0.5 * (u * (rho * rho - u * u).sqrt() + rho * rho * (u / rho).asin());
734            let g_ = |u: f64| -(rho * rho - u * u).powf(1.5) / 3.0;
735            let (u0, u1) = (-xc, l - xc);
736            let volume = PI
737                * ((rho * rho + yc * yc) * l - (u1.powi(3) - u0.powi(3)) / 3.0
738                    + 2.0 * yc * (f(u1) - f(u0)));
739            // ∫ x y² dx with x = u + xc.
740            let moment = PI
741                * ((rho * rho + yc * yc) * l * l / 2.0
742                    - ((u1.powi(4) - u0.powi(4)) / 4.0 + xc * (u1.powi(3) - u0.powi(3)) / 3.0)
743                    + 2.0 * yc * (g_(u1) - g_(u0) + xc * (f(u1) - f(u0))));
744            let area = 2.0 * PI * rho * (l + yc * ((u1 / rho).asin() - (u0 / rho).asin()));
745            let planform = 2.0 * (yc * l + f(u1) - f(u0));
746            let s = solid(NoseShape::Ogive {
747                radius_ratio: ratio,
748            });
749            close(s.volume_m3, volume, tol, "ogive volume");
750            close(s.centroid_m, moment / volume, tol, "ogive centroid");
751            close(s.wetted_area_m2, area, tol, "ogive area");
752            close(s.planform_area_m2, planform, tol, "ogive planform");
753        }
754
755        // Half a prolate spheroid, tip forward: centroid 3L/8 from the base; Crowell's area with
756        // e = √(1 − R²/L²); planform a half ellipse with centroid 4L/3π from the base.
757        let g = solid(NoseShape::Elliptical {});
758        let e = (1.0 - r * r / (l * l)).sqrt();
759        close(
760            g.volume_m3,
761            2.0 * PI * r * r * l / 3.0,
762            tol,
763            "ellipsoid volume",
764        );
765        close(g.centroid_m, 5.0 * l / 8.0, tol, "ellipsoid centroid");
766        close(
767            g.wetted_area_m2,
768            PI * r * r + PI * r * l * e.asin() / e,
769            tol,
770            "ellipsoid area",
771        );
772        close(
773            g.planform_area_m2,
774            PI * r * l / 2.0,
775            tol,
776            "ellipse planform",
777        );
778        close(
779            g.planform_centroid_m,
780            l - 4.0 * l / (3.0 * PI),
781            tol,
782            "ellipse planform centroid",
783        );
784        // An oblate half spheroid (L < R): area πR² + (πL²/2e) ln((1 + e)/(1 − e)), e = √(1 − L²/R²).
785        let oblate = revolve(
786            &Profile::nose(NoseShape::Elliptical {}, 0.03, r).unwrap(),
787            Wall::Filled {},
788        )
789        .unwrap();
790        let e = (1.0 - 0.03f64.powi(2) / (r * r)).sqrt();
791        let area = PI * r * r + PI * 0.03f64.powi(2) / (2.0 * e) * ((1.0 + e) / (1.0 - e)).ln();
792        close(oblate.wetted_area_m2, area, tol, "oblate area");
793
794        // Power series: V = πR²L/(2n+1), x̄ = L(2n+1)/(2n+2), planform 2RL/(n+1) at L(n+1)/(n+2);
795        // the paraboloid (n = ½) has area πR/(6L²) ((R² + 4L²)^{3/2} − R³).
796        for n in [0.3, 0.5, 0.75, 1.0] {
797            let g = solid(NoseShape::PowerSeries { exponent: n });
798            close(
799                g.volume_m3,
800                PI * r * r * l / (2.0 * n + 1.0),
801                tol,
802                "power volume",
803            );
804            close(
805                g.centroid_m,
806                l * (2.0 * n + 1.0) / (2.0 * n + 2.0),
807                tol,
808                "power centroid",
809            );
810            close(
811                g.planform_area_m2,
812                2.0 * r * l / (n + 1.0),
813                tol,
814                "power planform",
815            );
816            close(
817                g.planform_centroid_m,
818                l * (n + 1.0) / (n + 2.0),
819                tol,
820                "power planform centroid",
821            );
822        }
823        let g = solid(NoseShape::PowerSeries { exponent: 0.5 });
824        let area = PI * r / (6.0 * l * l) * ((r * r + 4.0 * l * l).powf(1.5) - r.powi(3));
825        close(g.wetted_area_m2, area, tol, "paraboloid area");
826
827        // Parabolic series: y = R(2ξ − Kξ²)/(2 − K).
828        for k in [0.0, 0.5, 0.75, 1.0] {
829            let g = solid(NoseShape::ParabolicSeries { parameter: k });
830            let v = 4.0 / 3.0 - k + k * k / 5.0;
831            let m = 1.0 - 0.8 * k + k * k / 6.0;
832            close(
833                g.volume_m3,
834                PI * r * r * l * v / (2.0 - k).powi(2),
835                tol,
836                "parabolic volume",
837            );
838            close(g.centroid_m, l * m / v, tol, "parabolic centroid");
839            close(
840                g.planform_area_m2,
841                2.0 * r * l * (1.0 - k / 3.0) / (2.0 - k),
842                tol,
843                "parabolic planform",
844            );
845            close(
846                g.planform_centroid_m,
847                l * (2.0 / 3.0 - k / 4.0) / (1.0 - k / 3.0),
848                tol,
849                "parabolic planform centroid",
850            );
851        }
852
853        // Haack series: V = πR²L(1/2 + 3C/16) and, integrating in θ, x̄ = L(11 + 3C)/(2(8 + 3C)).
854        for c in [0.0, 1.0 / 3.0, 2.0 / 3.0] {
855            let g = solid(NoseShape::Haack { parameter: c });
856            close(
857                g.volume_m3,
858                PI * r * r * l * (0.5 + 3.0 * c / 16.0),
859                tol,
860                "Haack volume",
861            );
862            close(
863                g.centroid_m,
864                l * (11.0 + 3.0 * c) / (2.0 * (8.0 + 3.0 * c)),
865                tol,
866                "Haack centroid",
867            );
868        }
869    }
870
871    #[test]
872    fn tips_stay_exact_where_the_formulas_cancel() {
873        // θ − sin 2θ/2 by series agrees with the direct form where both are accurate, and with
874        // (2/3)θ³ − (2/15)θ⁵ at small θ.
875        for theta in [0.1f64, 0.1 - 1e-12, 0.3] {
876            let direct = theta - (2.0 * theta).sin() / 2.0;
877            assert!(
878                (haack_core(theta) - direct).abs() <= 1e-13 * direct,
879                "{theta}"
880            );
881        }
882        let theta: f64 = 1e-3;
883        let series =
884            2.0 / 3.0 * theta.powi(3) - 2.0 / 15.0 * theta.powi(5) + 4.0 / 315.0 * theta.powi(7);
885        assert!((haack_core(theta) - series).abs() <= 1e-15 * series);
886        // A Haack transition's slope at its small end is infinite, never NaN.
887        let t = Profile::transition(NoseShape::VON_KARMAN, 0.02, 0.0508, 0.0785, false).unwrap();
888        assert_eq!(t.radius_and_slope(0.0), (0.0508, f64::INFINITY));
889        let nose = Profile::nose(NoseShape::VON_KARMAN, 0.3, 0.05).unwrap();
890        assert_eq!(nose.radius_and_slope(0.0), (0.0, f64::INFINITY));
891        for xi in [1e-18, 1e-12, 1e-6] {
892            let (r, slope) = nose.radius_and_slope(0.3 * xi);
893            assert!(r > 0.0 && slope.is_finite() && slope > 0.0, "{xi}");
894        }
895        // A tangent-ogive transition 500 times as long as its radius step still reaches both
896        // radii, and its volume lies between the two cylinders'.
897        let slender =
898            Profile::transition(NoseShape::TANGENT_OGIVE, 0.05, 0.025, 0.0251, false).unwrap();
899        assert!((slender.radius_m(0.0) - 0.025).abs() < 1e-15);
900        assert!((slender.radius_m(0.05) - 0.0251).abs() < 1e-15);
901        let g = crate::solids::revolve(&slender, crate::solids::Wall::Filled {}).unwrap();
902        let (lo, hi) = (PI * 0.025f64.powi(2) * 0.05, PI * 0.0251f64.powi(2) * 0.05);
903        assert!(g.volume_m3 > lo && g.volume_m3 < hi);
904        // The boundary ogive, whose arc center sits on the axis, is valid and ends at R.
905        let edge = Profile::nose(NoseShape::Ogive { radius_ratio: 0.2 }, 0.25, 0.05).unwrap();
906        assert!((edge.radius_m(0.25) - 0.05).abs() < 1e-15);
907        assert!(edge.radius_m(0.0).abs() < 1e-15);
908        // Clipped bulged ogives are rejected.
909        assert!(matches!(
910            Profile::transition(
911                NoseShape::Ogive { radius_ratio: 0.7 },
912                0.05,
913                0.02,
914                0.025,
915                true
916            ),
917            Err(DesignError::Geometry(_))
918        ));
919    }
920
921    #[test]
922    fn unknown_shape_fields_are_rejected() {
923        for bad in [
924            r#"{"kind":"conical","radius_ratio":2.0}"#,
925            r#"{"kind":"elliptical","parameter":0.5}"#,
926            r#"{"kind":"haack","parameter":0.0,"clipped":true}"#,
927        ] {
928            assert!(serde_json::from_str::<NoseShape>(bad).is_err(), "{bad}");
929        }
930        assert_eq!(
931            serde_json::from_str::<NoseShape>(r#"{"kind":"conical"}"#).unwrap(),
932            NoseShape::Conical {}
933        );
934        assert!(
935            serde_json::from_str::<crate::solids::Wall>(r#"{"kind":"filled","thickness_m":0.002}"#)
936                .is_err()
937        );
938    }
939
940    #[test]
941    fn profiles_round_trip_through_serde_and_reject_bad_data() {
942        let t = Profile::transition(NoseShape::LV_HAACK, 0.12, 0.04, 0.02, true).unwrap();
943        let json = serde_json::to_string(&t).unwrap();
944        assert_eq!(
945            json,
946            r#"{"shape":{"kind":"haack","parameter":0.3333333333333333},"length_m":0.12,"fore_radius_m":0.04,"aft_radius_m":0.02,"clipped":true}"#
947        );
948        let back: Profile = serde_json::from_str(&json).unwrap();
949        assert_eq!(back, t);
950        let bad =
951            r#"{"shape":{"kind":"conical"},"length_m":-1,"fore_radius_m":0,"aft_radius_m":0.02}"#;
952        assert!(serde_json::from_str::<Profile>(bad).is_err());
953        assert!(Profile::transition(NoseShape::Conical {}, 0.1, 0.0, 0.0, false).is_err());
954        let cylinder =
955            Profile::transition(NoseShape::Elliptical {}, 0.1, 0.02, 0.02, true).unwrap();
956        assert_eq!(cylinder.radius_and_slope(0.05), (0.02, 0.0));
957    }
958}