Skip to main content

hpr_design/
solids.rs

1//! Solids of revolution: volume, centroid, moments of inertia and surface areas of a profile swept
2//! about its axis, either filled or as a wall of constant thickness normal to the outer surface.
3//!
4//! **Integrals.** With outer radius `y(x)`, inner radius `r_i(x)` (zero when filled) and `x`
5//! measured aft of the forward end over `[0, L]`, per unit density:
6//!
7//! ```text
8//! V    = π ∫ (y² − r_i²) dx                     volume
9//! x̄    = π ∫ x (y² − r_i²) dx / V               centroid, aft of the forward end
10//! J_a  = (π/2) ∫ (y⁴ − r_i⁴) dx                 moment about the axis
11//! J_t  = π ∫ [(y⁴ − r_i⁴)/4 + x² (y² − r_i²)] dx − V x̄²    moment about a transverse axis
12//!                                                          through the centroid
13//! S    = 2π ∫ y √(1 + y′²) dx                   outer (wetted) area, ends excluded
14//! A_p  = 2 ∫ y dx,   x_p = ∫ x y dx / ∫ y dx    planform (side-view) area and its centroid
15//! ```
16//!
17//! Each slice is a thin annulus of radii `r_i < y` and thickness `dx`, with `dI_a = (π/2)(y⁴ −
18//! r_i⁴) dx` about the axis and `dI_t = (π/4)(y⁴ − r_i⁴) dx` about its own diameter (Meriam and
19//! Kraige, appendix B), moved to the reference plane by the parallel-axis theorem.
20//!
21//! **Walls.** A wall of thickness `t` is the set of points of the solid within `t` of the outer
22//! surface, so the thickness is measured normal to the surface, as a molded or laid-up shell is
23//! made. Its inner radius at station `x` is the lower envelope of circles of radius `t` centered on
24//! the profile:
25//!
26//! ```text
27//! r_i(x) = max(0, min_{|s − x| ≤ t} [ y(s) − √(t² − (x − s)²) ])
28//! ```
29//!
30//! A point below the profile is at least `t` from every surface point exactly when it lies below
31//! all those circles: if it lay above the lower half of the circle about some surface point, the
32//! continuous profile would cross the point's height closer than `t`, so the point would be in the
33//! wall anyway. The surface is the profile over its own length, ends included, with no extension
34//! past a cut end. At an end where the surface meets the end plane at an obtuse angle inside the
35//! wall, the wall's inner corner is rounded by the rim's circle instead of cut square. That removes
36//! `t² (tan φ − φ)/2` of section per unit rim length, with `φ` the surface's angle to the axis
37//! there: 3.1e-4 t² at 7°, and it grows without bound only as the end turns vertical, where a square
38//! cut would close the end with a disc. The integration is split where `r_i` reaches zero and where
39//! the nearest surface point moves between the lateral surface and a rim.
40//!
41//! The integrals use [`hpr_core::quadrature::integrate`] on integrands scaled to order one.
42//! See `docs/physics/mass.md`.
43
44use std::f64::consts::PI;
45
46use hpr_core::quadrature::{Tolerance, integrate};
47use serde::{Deserialize, Serialize};
48
49use crate::error::DesignError;
50use crate::shapes::{Profile, check_dimension};
51
52/// Whether a solid of revolution is filled or a wall.
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
55pub enum Wall {
56    /// Solid all the way to the axis.
57    Filled {},
58    /// A wall of constant thickness measured normal to the outer surface. A thickness of zero is a
59    /// surface with no wall: the part keeps its shape and weighs nothing, which is what OpenRocket
60    /// makes of a part written with no wall ([ADR-061][adr-061]).
61    ///
62    /// [adr-061]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-061-what-a-ork-leaves-unsaid-read-as-openrocket-reads-it-overrides-measured-two-departures-kept-2026-09-21
63    Shell {
64        /// Wall thickness, m; zero or more.
65        thickness_m: f64,
66    },
67}
68
69/// Volume, centroid, moments and areas of a solid of revolution per unit density; multiply the
70/// volume and moments by a density to get mass and inertia.
71#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
72pub struct RevolvedGeometry {
73    /// Volume, m³.
74    pub volume_m3: f64,
75    /// Centroid of the volume, m aft of the forward end.
76    pub centroid_m: f64,
77    /// Moment of inertia about the axis per unit density, `∫ r² dV`, m⁵.
78    pub axial_m5: f64,
79    /// Moment of inertia about a transverse axis through the centroid per unit density, m⁵.
80    pub transverse_m5: f64,
81    /// Outer surface area, ends excluded, m².
82    pub wetted_area_m2: f64,
83    /// Side-view (planform) area, m².
84    pub planform_area_m2: f64,
85    /// Centroid of the planform area, m aft of the forward end.
86    pub planform_centroid_m: f64,
87}
88
89/// Quadrature settings for smooth profile integrands.
90const SMOOTH: Tolerance = Tolerance {
91    relative: 1e-12,
92    absolute: 1e-14,
93    max_intervals: 4000,
94};
95
96/// Quadrature settings for wall integrands, whose inner radius comes from a numerical minimum.
97const WALL: Tolerance = Tolerance {
98    relative: 1e-11,
99    absolute: 1e-13,
100    max_intervals: 4000,
101};
102
103/// Computes the volume, moments and areas of `profile` swept about its axis with `wall`.
104///
105/// # Errors
106///
107/// - [`DesignError::Domain`] for a negative or non-finite wall thickness.
108/// - [`DesignError::Numerics`] if an integral doesn't converge.
109pub fn revolve(profile: &Profile, wall: Wall) -> Result<RevolvedGeometry, DesignError> {
110    let length = profile.length_m();
111    let scale = profile.max_radius_m();
112    let thickness = match wall {
113        Wall::Filled {} => None,
114        Wall::Shell { thickness_m } => {
115            check_dimension("wall thickness", thickness_m, true)?;
116            Some(thickness_m)
117        }
118    };
119    // Each half of the profile is integrated from its own end, in `u = s²` with `u` the
120    // normalized distance from that end: the substitution smooths the `u^(−1/2)` singularity of a
121    // blunt tip's surface integrand, and measuring from the end keeps the tip exact.
122    let halves = |f: &dyn Fn(f64, f64, f64) -> [f64; 5]| -> Result<[f64; 5], DesignError> {
123        let mut total = [0.0; 5];
124        for from_aft in [false, true] {
125            let piece = integrate(
126                |s| {
127                    let u = s * s;
128                    let (r, slope) = profile.at_distance(u * length, from_aft);
129                    let x = if from_aft { 1.0 - u } else { u };
130                    f(x, r / scale, slope).map(|v| 2.0 * s * v)
131                },
132                0.0,
133                std::f64::consts::FRAC_1_SQRT_2,
134                SMOOTH,
135            )?;
136            for (sum, value) in total.iter_mut().zip(piece.value) {
137                *sum += value;
138            }
139        }
140        Ok(total)
141    };
142
143    // Surfaces, and the filled solid's moments: [∫Y², ∫XY², ∫Y⁴, ∫X²Y²] over X.
144    let [wetted, planform, planform_moment, _, _] = halves(&|x, y, slope| {
145        // `hypot` keeps a very blunt tip's `y y′` from overflowing when squared.
146        let wetted = if y == 0.0 { 0.0 } else { y.hypot(y * slope) };
147        [wetted, y, x * y, 0.0, 0.0]
148    })?;
149    let [a, b, c, d, _] = halves(&|x, y, _| {
150        let y2 = y * y;
151        [y2, x * y2, y2 * y2, x * x * y2, 0.0]
152    })?;
153    let filled = [a, b, c, d];
154
155    // A wall subtracts the hollow's moments: [∫Yi², ∫XYi², ∫Yi⁴, ∫X²Yi²].
156    let moments = match thickness {
157        None => filled,
158        // No wall: no volume and no moments, taken as exact rather than as a difference of two
159        // equal integrals that would leave rounding behind.
160        Some(0.0) => [0.0; 4],
161        Some(t) => {
162            let inner = |x: f64| inner_radius(profile, x * length, t).0 / scale;
163            let integrand = |x: f64| {
164                let yi2 = inner(x).powi(2);
165                [yi2, x * yi2, yi2 * yi2, x * x * yi2]
166            };
167            let mut hollow = [0.0; 4];
168            // Split where the inner radius fills in and where the envelope's nearest surface point
169            // moves between the lateral surface and an end point: both are kinks.
170            let breaks = envelope_breaks(&|x: f64| {
171                let (r, source) = inner_radius(profile, x * length, t);
172                (r > 0.0, source)
173            });
174            for pair in breaks.windows(2) {
175                // Every piece is integrated, even one that looks filled in at its middle, so a
176                // hollow the crossing grid misses is still counted wherever quadrature nodes reach
177                // it.
178                let piece = integrate(integrand, pair[0], pair[1], WALL)?;
179                for (sum, value) in hollow.iter_mut().zip(piece.value) {
180                    *sum += value;
181                }
182            }
183            std::array::from_fn(|k| filled[k] - hollow[k])
184        }
185    };
186    let [area, first, fourth, second] = moments;
187    let r2l = scale * scale * length;
188    let volume = PI * r2l * area;
189    let centroid = if area > 0.0 {
190        length * first / area
191    } else {
192        0.5 * length
193    };
194    let axial = 0.5 * PI * r2l * scale * scale * fourth;
195    let about_fore = PI * (0.25 * r2l * scale * scale * fourth + r2l * length * length * second);
196    let transverse = about_fore - volume * centroid * centroid;
197    // The subtraction may round a tiny moment below zero; anything larger is a numerical fault.
198    if transverse < -1e-9 * about_fore {
199        return Err(DesignError::Geometry(format!(
200            "the transverse moment came out negative ({transverse:e} m⁵)"
201        )));
202    }
203    Ok(RevolvedGeometry {
204        volume_m3: volume,
205        centroid_m: centroid,
206        axial_m5: axial,
207        transverse_m5: transverse.max(0.0),
208        wetted_area_m2: 2.0 * PI * scale * length * wetted,
209        planform_area_m2: 2.0 * scale * length * planform,
210        planform_centroid_m: if planform > 0.0 {
211            length * planform_moment / planform
212        } else {
213            0.5 * length
214        },
215    })
216}
217
218/// Which surface point sets a wall's inner radius.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220enum Source {
221    /// A point of the lateral surface between the ends.
222    Surface,
223    /// The rim at the forward end.
224    Fore,
225    /// The rim at the aft end.
226    Aft,
227}
228
229/// The inner radius of a wall of thickness `t` at station `x` (both m), and which surface point
230/// sets it: the lower envelope of circles of radius `t` centered on the profile, over the profile's
231/// own length.
232fn inner_radius(profile: &Profile, x: f64, t: f64) -> (f64, Source) {
233    let length = profile.length_m();
234    let bound = |s: f64| -> f64 {
235        let dx = x - s;
236        profile.radius_m(s) - (t * t - dx * dx).max(0.0).sqrt()
237    };
238    // The window of surface points within `t` of the station, on the profile.
239    let lo = (x - t).max(0.0);
240    let hi = (x + t).min(length);
241    // A coarse scan finds the basin, and golden-section search refines it.
242    const SAMPLES: usize = 32;
243    let step = (hi - lo) / SAMPLES as f64;
244    let sample = |k: usize| lo + step * (k as f64 + 0.5);
245    let best = (0..SAMPLES)
246        .min_by(|&i, &j| bound(sample(i)).total_cmp(&bound(sample(j))))
247        .unwrap_or(0);
248    let mut left = (sample(best) - step).max(lo);
249    let mut right = (sample(best) + step).min(hi);
250    let ratio = 0.5 * (5f64.sqrt() - 1.0);
251    let mut a = right - ratio * (right - left);
252    let mut b = left + ratio * (right - left);
253    let (mut fa, mut fb) = (bound(a), bound(b));
254    for _ in 0..80 {
255        if fa <= fb {
256            right = b;
257            b = a;
258            fb = fa;
259            a = right - ratio * (right - left);
260            fa = bound(a);
261        } else {
262            left = a;
263            a = b;
264            fa = fb;
265            b = left + ratio * (right - left);
266            fb = bound(b);
267        }
268        // Near the minimum the bound is quadratic in s, with curvature of order 1/t, so locating s
269        // to 1e-9 t fixes the value to about 1e-18 t.
270        if right - left <= 1e-9 * t {
271            break;
272        }
273    }
274    let mut minimum = (fa.min(fb).min(bound(sample(best))), Source::Surface);
275    // The ends are part of the surface, and a minimum can sit exactly on one, where the search above
276    // only approaches it from inside.
277    for (end, source) in [(0.0, Source::Fore), (length, Source::Aft)] {
278        if (x - end).abs() <= t {
279            let value = bound(end);
280            if value < minimum.0 {
281                minimum = (value, source);
282            }
283        }
284    }
285    (minimum.0.max(0.0), minimum.1)
286}
287
288/// Normalized stations bounding the pieces over which the wall's state is smooth: 0, every point
289/// where `state` (whether the hollow is open, and which surface point sets its radius) changes, to
290/// near machine precision, and 1.
291fn envelope_breaks(state: &impl Fn(f64) -> (bool, Source)) -> Vec<f64> {
292    const GRID: usize = 128;
293    let mut points = vec![0.0];
294    let mut previous = state(0.0);
295    for k in 1..=GRID {
296        let x = k as f64 / GRID as f64;
297        let now = state(x);
298        if now != previous {
299            let (mut lo, mut hi) = ((k - 1) as f64 / GRID as f64, x);
300            for _ in 0..60 {
301                let mid = 0.5 * (lo + hi);
302                if state(mid) == previous {
303                    lo = mid;
304                } else {
305                    hi = mid;
306                }
307            }
308            points.push(0.5 * (lo + hi));
309        }
310        previous = now;
311    }
312    points.push(1.0);
313    points
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::shapes::NoseShape;
320
321    fn close(got: f64, want: f64, rel: f64, what: &str) {
322        let err = ((got - want) / want).abs();
323        assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
324    }
325
326    #[derive(serde::Deserialize)]
327    struct Fixture {
328        cases: Vec<Case>,
329    }
330
331    #[derive(serde::Deserialize)]
332    struct Case {
333        input: serde_json::Value,
334        expected: RevolvedGeometry,
335    }
336
337    /// Every shape family, as noses and as transitions both ways and clipped, against mpmath's
338    /// 40-digit integrals of the defining formulas (`validation/oracles/design/shapes.py`).
339    #[test]
340    fn filled_solids_match_the_mpmath_references() {
341        let text = include_str!("../../../validation/fixtures/design/shape-integrals.json");
342        let fixture: Fixture = serde_json::from_str(text).unwrap();
343        assert_eq!(fixture.cases.len(), 22);
344        for case in fixture.cases {
345            let profile = fixture_profile(&case.input);
346            let got = revolve(&profile, Wall::Filled {}).unwrap();
347            let want = case.expected;
348            let label = case.input.to_string();
349            close(
350                got.volume_m3,
351                want.volume_m3,
352                1e-12,
353                &format!("volume {label}"),
354            );
355            close(
356                got.centroid_m,
357                want.centroid_m,
358                1e-12,
359                &format!("centroid {label}"),
360            );
361            close(
362                got.axial_m5,
363                want.axial_m5,
364                1e-12,
365                &format!("axial {label}"),
366            );
367            close(
368                got.transverse_m5,
369                want.transverse_m5,
370                1e-12,
371                &format!("transverse {label}"),
372            );
373            close(
374                got.wetted_area_m2,
375                want.wetted_area_m2,
376                1e-12,
377                &format!("wetted {label}"),
378            );
379            close(
380                got.planform_area_m2,
381                want.planform_area_m2,
382                1e-12,
383                &format!("planform {label}"),
384            );
385            close(
386                got.planform_centroid_m,
387                want.planform_centroid_m,
388                1e-12,
389                &format!("planform centroid {label}"),
390            );
391        }
392    }
393
394    #[derive(serde::Deserialize)]
395    struct WallFixture {
396        cases: Vec<WallCase>,
397    }
398
399    #[derive(serde::Deserialize)]
400    struct WallCase {
401        input: serde_json::Value,
402        wall_thickness_m: f64,
403        expected: WallExpected,
404    }
405
406    #[derive(serde::Deserialize)]
407    struct WallExpected {
408        volume_m3: f64,
409        centroid_m: f64,
410        axial_m5: f64,
411        transverse_m5: f64,
412    }
413
414    /// A fixture case's input as a profile (the fixture names the shape parameter `parameter`).
415    fn fixture_profile(input: &serde_json::Value) -> Profile {
416        let mut input = input.clone();
417        let object = input.as_object_mut().unwrap();
418        let kind = object.remove("shape").unwrap();
419        let mut shape = serde_json::json!({ "kind": kind });
420        if let Some(parameter) = object.remove("parameter") {
421            let key = match kind.as_str().unwrap() {
422                "ogive" => "radius_ratio",
423                "power_series" => "exponent",
424                _ => "parameter",
425            };
426            shape[key] = parameter;
427        }
428        object.insert("shape".to_owned(), shape);
429        serde_json::from_value(input).unwrap()
430    }
431
432    /// Walls of every shape family, as noses and as transitions both ways, clipped and not (blunt
433    /// unclipped ends included), against `validation/oracles/design/walls.py`, which finds the
434    /// envelope from the roots of its derivative at 25 digits.
435    #[test]
436    fn walls_match_the_mpmath_references() {
437        let text = include_str!("../../../validation/fixtures/design/wall-integrals.json");
438        let fixture: WallFixture = serde_json::from_str(text).unwrap();
439        assert_eq!(fixture.cases.len(), 20);
440        for case in fixture.cases {
441            let profile = fixture_profile(&case.input);
442            let wall = Wall::Shell {
443                thickness_m: case.wall_thickness_m,
444            };
445            let got = revolve(&profile, wall).unwrap();
446            let want = case.expected;
447            let label = format!("{} t = {}", case.input, case.wall_thickness_m);
448            close(
449                got.volume_m3,
450                want.volume_m3,
451                1e-10,
452                &format!("volume {label}"),
453            );
454            close(
455                got.centroid_m,
456                want.centroid_m,
457                1e-10,
458                &format!("centroid {label}"),
459            );
460            close(
461                got.axial_m5,
462                want.axial_m5,
463                1e-10,
464                &format!("axial {label}"),
465            );
466            close(
467                got.transverse_m5,
468                want.transverse_m5,
469                1e-10,
470                &format!("transverse {label}"),
471            );
472        }
473    }
474
475    #[test]
476    fn extreme_parameters_stay_accurate_or_fail_loudly() {
477        // A huge ogive radius is a cone.
478        let cone = revolve(
479            &Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap(),
480            Wall::Filled {},
481        )
482        .unwrap();
483        for ratio in [1e6, 1e12] {
484            let profile = Profile::nose(
485                NoseShape::Ogive {
486                    radius_ratio: ratio,
487                },
488                0.3,
489                0.05,
490            )
491            .unwrap();
492            assert!(
493                (profile.radius_m(0.001) - 0.05 / 300.0).abs() < 1e-9,
494                "{ratio}"
495            );
496            let g = revolve(&profile, Wall::Filled {}).unwrap();
497            close(g.volume_m3, cone.volume_m3, 1e-5, &format!("ogive {ratio}"));
498        }
499        // The bluntest power series accepted integrates as a nose and as transitions both ways:
500        // V = πR²L/(2n + 1) for the nose, and πL(R₁² + 2R₁ΔR/(n + 1) + ΔR²/(2n + 1)) for a
501        // transition growing from R₁ by ΔR (a boattail is its mirror image).
502        let n = crate::shapes::MIN_POWER_EXPONENT;
503        let shape = NoseShape::PowerSeries { exponent: n };
504        let g = revolve(&Profile::nose(shape, 0.2, 0.05).unwrap(), Wall::Filled {}).unwrap();
505        close(
506            g.volume_m3,
507            PI * 0.05 * 0.05 * 0.2 / (2.0 * n + 1.0),
508            1e-10,
509            "blunt power nose",
510        );
511        assert!(g.wetted_area_m2.is_finite());
512        let (r1, dr, l): (f64, f64, f64) = (0.0381, 0.0127, 0.1);
513        let volume = PI * l * (r1 * r1 + 2.0 * r1 * dr / (n + 1.0) + dr * dr / (2.0 * n + 1.0));
514        for (fore, aft) in [(r1, r1 + dr), (r1 + dr, r1)] {
515            let profile = Profile::transition(shape, l, fore, aft, false).unwrap();
516            let g = revolve(&profile, Wall::Filled {}).unwrap();
517            close(g.volume_m3, volume, 1e-10, "blunt power transition");
518            assert!(g.wetted_area_m2.is_finite());
519            revolve(&profile, Wall::Shell { thickness_m: 0.002 }).unwrap();
520        }
521    }
522
523    #[test]
524    fn a_tube_matches_the_hollow_cylinder_formulas() {
525        let (l, r, t) = (0.5, 0.05, 0.002);
526        let profile = Profile::transition(NoseShape::Conical {}, l, r, r, false).unwrap();
527        let g = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
528        let ri = r - t;
529        let v = PI * (r * r - ri * ri) * l;
530        close(g.volume_m3, v, 1e-12, "volume");
531        close(g.centroid_m, l / 2.0, 1e-12, "centroid");
532        close(g.axial_m5, 0.5 * v * (r * r + ri * ri), 1e-11, "axial");
533        close(
534            g.transverse_m5,
535            v * ((r * r + ri * ri) / 4.0 + l * l / 12.0),
536            1e-11,
537            "transverse",
538        );
539        close(g.wetted_area_m2, 2.0 * PI * r * l, 1e-12, "wetted");
540        close(g.planform_area_m2, 2.0 * r * l, 1e-12, "planform");
541    }
542
543    #[test]
544    fn a_conical_wall_is_the_cone_minus_its_offset_cone() {
545        // The inner surface of a cone with normal wall t is the same cone moved aft by t / sin β,
546        // with β the half-angle: r_i = k (x − x0), k = R/L, x0 = t √(1 + k²) / k.
547        let (l, r, t): (f64, f64, f64) = (0.3, 0.05, 0.003);
548        let k = r / l;
549        let x0 = t * (1.0 + k * k).sqrt() / k;
550        let profile = Profile::nose(NoseShape::Conical {}, l, r).unwrap();
551        let g = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
552        // Integrals of the outer cone and the hollow cone over [x0, L], by hand.
553        let u = l - x0;
554        let v_out = PI * r * r * l / 3.0;
555        let v_in = PI * k * k * u.powi(3) / 3.0;
556        let m_out = PI * k * k * l.powi(4) / 4.0;
557        let m_in = PI * k * k * (u.powi(4) / 4.0 + x0 * u.powi(3) / 3.0);
558        let volume = v_out - v_in;
559        close(g.volume_m3, volume, 1e-10, "volume");
560        close(g.centroid_m, (m_out - m_in) / volume, 1e-10, "centroid");
561        // ∫ y⁴ dx over the cones.
562        let a_out = 0.5 * PI * k.powi(4) * l.powi(5) / 5.0;
563        let a_in = 0.5 * PI * k.powi(4) * u.powi(5) / 5.0;
564        close(g.axial_m5, a_out - a_in, 1e-10, "axial");
565        // ∫ x² y² dx about the tip plane.
566        let s_out = PI * k * k * l.powi(5) / 5.0;
567        let s_in =
568            PI * k * k * (u.powi(5) / 5.0 + 2.0 * x0 * u.powi(4) / 4.0 + x0 * x0 * u.powi(3) / 3.0);
569        let transverse =
570            0.5 * (a_out - a_in) + (s_out - s_in) - volume * ((m_out - m_in) / volume).powi(2);
571        close(g.transverse_m5, transverse, 1e-9, "transverse");
572        close(
573            g.wetted_area_m2,
574            PI * r * (r * r + l * l).sqrt(),
575            1e-12,
576            "wetted",
577        );
578    }
579
580    #[test]
581    fn a_tangent_ogive_wall_is_bounded_by_the_concentric_arc() {
582        // The inner surface is the arc of radius ρ − t about the same center (L, R − ρ).
583        let (l, r, t): (f64, f64, f64) = (0.25, 0.04, 0.002);
584        let rho = (r * r + l * l) / (2.0 * r);
585        let (a, c) = (rho - t, r - rho);
586        let u0 = (a * a - c * c).sqrt();
587        // ∫_0^{u0} (√(a² − u²) + c)² du, with u = L − x.
588        let hollow = PI
589            * ((a * a + c * c) * u0 - u0.powi(3) / 3.0
590                + c * (u0 * (a * a - u0 * u0).sqrt() + a * a * (u0 / a).asin()));
591        let outer =
592            PI * (l * rho * rho - l.powi(3) / 3.0 - (rho - r) * rho * rho * (l / rho).asin());
593        let profile = Profile::nose(NoseShape::TANGENT_OGIVE, l, r).unwrap();
594        let wall = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
595        let filled = revolve(&profile, Wall::Filled {}).unwrap();
596        close(filled.volume_m3, outer, 1e-12, "filled volume");
597        close(wall.volume_m3, outer - hollow, 1e-10, "wall volume");
598    }
599
600    #[test]
601    fn wall_mass_is_continuous_as_an_end_turns_vertical() {
602        // A power series with n just below 1 has an infinite slope at its small end in floating
603        // point, while the cone's is finite; their profiles differ by about 1e-6 of the volume, and
604        // so must their walls. Extending cut ends along finite tangents made this 8% on a 5 mm
605        // transition.
606        for length in [0.005, 0.01, 0.05] {
607            let wall = |shape| {
608                let profile = Profile::transition(shape, length, 0.0381, 0.0508, false).unwrap();
609                let filled = revolve(&profile, Wall::Filled {}).unwrap().volume_m3;
610                let shell = revolve(&profile, Wall::Shell { thickness_m: 0.002 })
611                    .unwrap()
612                    .volume_m3;
613                (filled, shell)
614            };
615            let (cone_filled, cone_wall) = wall(NoseShape::Conical {});
616            let (power_filled, power_wall) = wall(NoseShape::PowerSeries { exponent: 0.99999 });
617            let filled_change = ((power_filled - cone_filled) / cone_filled).abs();
618            let wall_change = ((power_wall - cone_wall) / cone_wall).abs();
619            assert!(
620                wall_change < 1e-5,
621                "{length}: filled {filled_change:e}, wall {wall_change:e}"
622            );
623        }
624    }
625
626    #[test]
627    fn a_nearly_filled_cone_matches_the_offset_cone() {
628        // A cone with a wall so thick that the hollow is only the last 3.8 mm of 300 mm: the
629        // offset-cone formula still holds, and the fill-in point is found to high precision.
630        let (l, r, t): (f64, f64, f64) = (0.3, 0.05, 0.0487);
631        let k = r / l;
632        let x0 = t * (1.0 + k * k).sqrt() / k;
633        assert!(l - x0 < l / 64.0);
634        let hollow = PI * k * k * (l - x0).powi(3) / 3.0;
635        let g = revolve(
636            &Profile::nose(NoseShape::Conical {}, l, r).unwrap(),
637            Wall::Shell { thickness_m: t },
638        )
639        .unwrap();
640        close(
641            g.volume_m3,
642            PI * r * r * l / 3.0 - hollow,
643            1e-12,
644            "thick cone",
645        );
646        assert!(hollow > 1e-9);
647    }
648
649    #[test]
650    fn a_thick_wall_fills_the_solid_and_a_thin_one_is_area_times_thickness() {
651        let profile = Profile::nose(NoseShape::VON_KARMAN, 0.3, 0.05).unwrap();
652        let filled = revolve(&profile, Wall::Filled {}).unwrap();
653        let thick = revolve(&profile, Wall::Shell { thickness_m: 0.2 }).unwrap();
654        close(thick.volume_m3, filled.volume_m3, 1e-12, "thick wall");
655        close(
656            thick.transverse_m5,
657            filled.transverse_m5,
658            1e-12,
659            "thick wall inertia",
660        );
661        // A thin wall's volume approaches S t (1 − t κ̄/2...) with an error of order t².
662        let t = 1e-5;
663        let thin = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
664        close(thin.volume_m3, filled.wetted_area_m2 * t, 2e-3, "thin wall");
665        assert!(revolve(&profile, Wall::Shell { thickness_m: -1.0 }).is_err());
666        // No wall at all: the shape is kept, and the part weighs nothing (ADR-061).
667        let none = revolve(&profile, Wall::Shell { thickness_m: 0.0 }).unwrap();
668        assert_eq!(
669            (none.volume_m3, none.axial_m5, none.transverse_m5),
670            (0.0, 0.0, 0.0)
671        );
672        assert_eq!(none.wetted_area_m2, filled.wetted_area_m2);
673        assert_eq!(none.planform_area_m2, filled.planform_area_m2);
674        assert!(none.centroid_m.is_finite());
675    }
676}