Skip to main content

hpr_design/
parts.rs

1//! Components other than fins: nose cones, body tubes, transitions, inner tubes, centering rings
2//! and bulkheads, launch lugs, rail buttons, mass components, parachutes, streamers and shock
3//! cords, each with its mass properties from geometry.
4//!
5//! **Frame.** Each part's frame has body axes and its origin on the body axis at the part's forward
6//! end (a nose cone's tip), so the part lies at `z ≤ 0` (`crate::mass`). Radial placements use a
7//! distance from the axis and a roll angle from `x_B` toward `y_B`.
8//!
9//! **Standard solids** (Meriam and Kraige, appendix B), for mass `m`:
10//!
11//! ```text
12//! hollow cylinder, radii R ≥ r, length L:  I_axis = m (R² + r²)/2,  I_across = m ((R² + r²)/4 + L²/12)
13//! solid cylinder, radius a, length h:      I_axis = m a²/2,          I_across = m (3a² + h²)/12
14//! ```
15//!
16//! Nose cones and transitions use [`crate::solids::revolve`]. **Shoulders** are hollow cylinders
17//! beyond the profile's end, and a capped shoulder is closed by a disc of the shoulder's inner
18//! radius and wall thickness, flush with its far end. **Recovery parts and mass components** are
19//! packed into solid cylinders (OpenRocket technical documentation v13.05, Table 5.1, p. 75, treats
20//! them the same way). A parachute's mass is its canopy, `π D²/4` times the fabric's surface
21//! density (the nominal area of a flat circular canopy), plus its shroud lines, count times length
22//! times line density. See `docs/physics/mass.md`.
23
24use std::f64::consts::PI;
25
26use hpr_core::DVec3;
27use serde::{Deserialize, Serialize};
28
29use crate::error::DesignError;
30use crate::mass::{MassProperties, Placement};
31use crate::material::Material;
32use crate::shapes::{NoseShape, Profile, check_dimension};
33use crate::solids::{Wall, revolve};
34
35/// A hollow cylinder of `density` on the axis with its forward end at the origin. A thickness
36/// equal to the outer radius gives a solid cylinder.
37///
38/// A thickness of **zero** is allowed, and gives a cylinder of no mass. That is not a mistake
39/// waiting to happen: a tube whose inner radius equals its outer radius is a real thing to say
40/// about a part, imported designs say it, and the formula below already answers it correctly.
41/// Refusing it would leave a reader no choice but to invent a wall: a solid coupler filling a
42/// 50 mm airframe for 180 mm weighs a few hundred grams that the design never had.
43pub(crate) fn hollow_cylinder(
44    part: &'static str,
45    density_kg_m3: f64,
46    length_m: f64,
47    outer_radius_m: f64,
48    thickness_m: f64,
49) -> Result<MassProperties, DesignError> {
50    check_dimension("length", length_m, false)?;
51    check_dimension("outer radius", outer_radius_m, false)?;
52    check_dimension("wall thickness", thickness_m, true)?;
53    if thickness_m > outer_radius_m {
54        return Err(DesignError::Geometry(format!(
55            "{part}: wall thickness {thickness_m} m exceeds the outer radius {outer_radius_m} m"
56        )));
57    }
58    let (big, small) = (outer_radius_m, outer_radius_m - thickness_m);
59    let mass = density_kg_m3 * PI * (big * big - small * small) * length_m;
60    let radii = big * big + small * small;
61    Ok(MassProperties::axisymmetric(
62        mass,
63        DVec3::new(0.0, 0.0, -0.5 * length_m),
64        0.5 * mass * radii,
65        mass * (0.25 * radii + length_m * length_m / 12.0),
66    ))
67}
68
69/// A solid cylinder of mass `mass_kg` along the axis, forward end at the origin.
70fn solid_cylinder(mass_kg: f64, length_m: f64, radius_m: f64) -> MassProperties {
71    MassProperties::axisymmetric(
72        mass_kg,
73        DVec3::new(0.0, 0.0, -0.5 * length_m),
74        0.5 * mass_kg * radius_m * radius_m,
75        mass_kg * (3.0 * radius_m * radius_m + length_m * length_m) / 12.0,
76    )
77}
78
79/// Checks that an angle is finite.
80fn check_angle(what: &'static str, value: f64) -> Result<(), DesignError> {
81    if value.is_finite() {
82        Ok(())
83    } else {
84        Err(DesignError::Domain { what, value })
85    }
86}
87
88/// A cylindrical extension of a nose cone or transition that fits inside the adjoining tube.
89#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct Shoulder {
92    /// Length, m.
93    pub length_m: f64,
94    /// Outer radius, m.
95    pub outer_radius_m: f64,
96    /// Wall thickness, m.
97    pub thickness_m: f64,
98    /// Whether a disc closes the shoulder's far end.
99    #[serde(default)]
100    pub capped: bool,
101}
102
103impl Shoulder {
104    /// Mass properties with the shoulder's near end at `z = near_z_m`, extending forward
105    /// (`forward`) or aft.
106    fn mass_properties(
107        &self,
108        density_kg_m3: f64,
109        near_z_m: f64,
110        forward: bool,
111    ) -> Result<MassProperties, DesignError> {
112        let tube = hollow_cylinder(
113            "shoulder",
114            density_kg_m3,
115            self.length_m,
116            self.outer_radius_m,
117            self.thickness_m,
118        )?;
119        // The tube's frame has its forward end at the origin; put that end in place.
120        let fore_z = if forward {
121            near_z_m + self.length_m
122        } else {
123            near_z_m
124        };
125        let mut parts = vec![tube.translated(DVec3::new(0.0, 0.0, fore_z))];
126        let inner = self.outer_radius_m - self.thickness_m;
127        if self.capped && inner > 0.0 && self.thickness_m <= self.length_m {
128            let cap = hollow_cylinder(
129                "shoulder cap",
130                density_kg_m3,
131                self.thickness_m,
132                inner,
133                inner,
134            )?;
135            let cap_fore = if forward {
136                fore_z
137            } else {
138                fore_z - self.length_m + self.thickness_m
139            };
140            parts.push(cap.translated(DVec3::new(0.0, 0.0, cap_fore)));
141        } else if self.capped {
142            return Err(DesignError::Geometry(
143                "a capped shoulder needs a hollow tube at least as long as its wall is thick"
144                    .to_owned(),
145            ));
146        }
147        Ok(MassProperties::combine(&parts))
148    }
149}
150
151/// A nose cone: a profile with its tip forward, and an optional shoulder aft of its base.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct NoseCone {
155    /// Profile shape.
156    pub shape: NoseShape,
157    /// Length from tip to base, m.
158    pub length_m: f64,
159    /// Base radius, m.
160    pub base_radius_m: f64,
161    /// Filled, or a wall of a thickness.
162    pub wall: Wall,
163    /// Optional shoulder.
164    #[serde(default)]
165    pub shoulder: Option<Shoulder>,
166    /// Material (bulk).
167    pub material: Material,
168}
169
170impl NoseCone {
171    /// The outer profile.
172    ///
173    /// # Errors
174    ///
175    /// As [`Profile::nose`].
176    pub fn profile(&self) -> Result<Profile, DesignError> {
177        Profile::nose(self.shape, self.length_m, self.base_radius_m)
178    }
179
180    /// Mass properties in the nose cone's frame (origin at the tip).
181    ///
182    /// # Errors
183    ///
184    /// Geometry, material and numerical errors from the profile, the solid and the shoulder.
185    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
186        let density = self.material.bulk_kg_m3("nose cone")?;
187        let body = revolved(&self.profile()?, self.wall, density)?;
188        match &self.shoulder {
189            None => Ok(body),
190            Some(shoulder) => {
191                let aft = shoulder.mass_properties(density, -self.length_m, false)?;
192                Ok(MassProperties::combine([&body, &aft]))
193            }
194        }
195    }
196}
197
198/// Mass properties of a revolved profile of `density`, forward end at the origin.
199fn revolved(profile: &Profile, wall: Wall, density: f64) -> Result<MassProperties, DesignError> {
200    let g = revolve(profile, wall)?;
201    Ok(MassProperties::axisymmetric(
202        density * g.volume_m3,
203        DVec3::new(0.0, 0.0, -g.centroid_m),
204        density * g.axial_m5,
205        density * g.transverse_m5,
206    ))
207}
208
209/// A transition between two radii, with optional shoulders at either end.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
211#[serde(deny_unknown_fields)]
212pub struct Transition {
213    /// Profile shape.
214    pub shape: NoseShape,
215    /// Whether the profile is clipped from a longer nose cone (`crate::shapes`).
216    #[serde(default)]
217    pub clipped: bool,
218    /// Length, m.
219    pub length_m: f64,
220    /// Radius at the forward end, m.
221    pub fore_radius_m: f64,
222    /// Radius at the aft end, m.
223    pub aft_radius_m: f64,
224    /// Filled, or a wall of a thickness.
225    pub wall: Wall,
226    /// Optional shoulder forward of the fore end.
227    #[serde(default)]
228    pub fore_shoulder: Option<Shoulder>,
229    /// Optional shoulder aft of the aft end.
230    #[serde(default)]
231    pub aft_shoulder: Option<Shoulder>,
232    /// Material (bulk).
233    pub material: Material,
234}
235
236impl Transition {
237    /// The outer profile.
238    ///
239    /// # Errors
240    ///
241    /// As [`Profile::transition`].
242    pub fn profile(&self) -> Result<Profile, DesignError> {
243        Profile::transition(
244            self.shape,
245            self.length_m,
246            self.fore_radius_m,
247            self.aft_radius_m,
248            self.clipped,
249        )
250    }
251
252    /// Mass properties in the transition's frame (origin at its fore end, so a fore shoulder lies
253    /// at `z > 0`).
254    ///
255    /// # Errors
256    ///
257    /// Geometry, material and numerical errors from the profile, the solid and the shoulders.
258    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
259        let density = self.material.bulk_kg_m3("transition")?;
260        let mut parts = vec![revolved(&self.profile()?, self.wall, density)?];
261        if let Some(shoulder) = &self.fore_shoulder {
262            parts.push(shoulder.mass_properties(density, 0.0, true)?);
263        }
264        if let Some(shoulder) = &self.aft_shoulder {
265            parts.push(shoulder.mass_properties(density, -self.length_m, false)?);
266        }
267        Ok(MassProperties::combine(&parts))
268    }
269}
270
271/// An airframe tube.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
273#[serde(deny_unknown_fields)]
274pub struct BodyTube {
275    /// Length, m.
276    pub length_m: f64,
277    /// Outer radius, m.
278    pub outer_radius_m: f64,
279    /// Wall thickness, m.
280    pub thickness_m: f64,
281    /// Material (bulk).
282    pub material: Material,
283}
284
285impl BodyTube {
286    /// Mass properties in the tube's frame.
287    ///
288    /// A tube of no length weighs nothing, whatever its radius: it has no wall to weigh. Only a
289    /// pod may hold one ([`PodSet`]), as OpenRocket's "phantom body" does, to hang fins or a lug
290    /// off the airframe's axis; a stage refuses a body component of no length.
291    ///
292    /// # Errors
293    ///
294    /// Geometry and material errors.
295    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
296        let density = self.material.bulk_kg_m3("body tube")?;
297        if self.length_m == 0.0 {
298            check_dimension("outer radius", self.outer_radius_m, true)?;
299            check_dimension("wall thickness", self.thickness_m, true)?;
300            if self.thickness_m > self.outer_radius_m {
301                return Err(DesignError::Geometry(format!(
302                    "body tube: wall thickness {} m exceeds the outer radius {} m",
303                    self.thickness_m, self.outer_radius_m
304                )));
305            }
306            return Ok(MassProperties::ZERO);
307        }
308        hollow_cylinder(
309            "body tube",
310            density,
311            self.length_m,
312            self.outer_radius_m,
313            self.thickness_m,
314        )
315    }
316}
317
318/// A tube inside the airframe: a coupler, a motor mount tube, an engine block or thrust ring. It
319/// may sit off the axis, and it may be a cluster: several like tubes side by side, as in a
320/// cluster's motor mount.
321///
322/// **A cluster.** [`Self::cluster_m`] lists where each tube's axis sits, `[x, y]` in body axes
323/// from the axis the radial offset and angle give. The tubes are the one tube written here,
324/// repeated at each place: their mass is the sum of the copies, each with its own parallel-axis
325/// term. What the tube holds (an engine block, a motor) is repeated in every tube in the same way
326/// (`docs/physics/design.md`, the decision record on clusters, [ADR-075][adr-075]).
327///
328/// [adr-075]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-075-a-cluster-is-one-tube-repeated-and-a-motor-in-it-one-motor-per-tube-2026-09-25
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
330#[serde(deny_unknown_fields)]
331pub struct InnerTube {
332    /// Length, m.
333    pub length_m: f64,
334    /// Outer radius, m.
335    pub outer_radius_m: f64,
336    /// Wall thickness, m.
337    pub thickness_m: f64,
338    /// Distance of the tube's axis from the body axis, m.
339    #[serde(default)]
340    pub radial_offset_m: f64,
341    /// Roll angle of that offset from `x_B` toward `y_B`, rad.
342    #[serde(default)]
343    pub angle_rad: f64,
344    /// Material (bulk).
345    pub material: Material,
346    /// A cluster's tubes: each tube's axis, `[x, y]` in body axes, m, measured from the axis the
347    /// radial offset and angle give. Empty (the default) for one tube on that axis.
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub cluster_m: Vec<[f64; 2]>,
350}
351
352impl InnerTube {
353    /// Mass properties in the tube's frame: every tube of a cluster.
354    ///
355    /// # Errors
356    ///
357    /// Geometry and material errors, and [`DesignError::Domain`] for a cluster offset that is not
358    /// finite.
359    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
360        check_dimension("inner tube radial offset", self.radial_offset_m, true)?;
361        check_angle("inner tube angle", self.angle_rad)?;
362        let density = self.material.bulk_kg_m3("inner tube")?;
363        let tube = hollow_cylinder(
364            "inner tube",
365            density,
366            self.length_m,
367            self.outer_radius_m,
368            self.thickness_m,
369        )?
370        .translated(DVec3::new(self.radial_offset_m, 0.0, 0.0))
371        .rolled(self.angle_rad);
372        Ok(MassProperties::copied(tube, &self.tubes_m()?))
373    }
374
375    /// Where each tube sits, `[x, y]` from the axis the radial offset and angle give, m: the
376    /// cluster's places, or `[0, 0]` alone for one tube.
377    ///
378    /// # Errors
379    ///
380    /// [`DesignError::Domain`] for a cluster offset that is not finite.
381    pub fn tubes_m(&self) -> Result<Vec<[f64; 2]>, DesignError> {
382        if self.cluster_m.is_empty() {
383            return Ok(vec![[0.0, 0.0]]);
384        }
385        for &[x, y] in &self.cluster_m {
386            for value in [x, y] {
387                if !value.is_finite() {
388                    return Err(DesignError::Domain {
389                        what: "inner tube cluster offset (m)",
390                        value,
391                    });
392                }
393            }
394        }
395        Ok(self.cluster_m.clone())
396    }
397}
398
399/// Pods beside the airframe: side pods, or outboard motor pods. A pod set attaches to a body tube
400/// like a fin set, and its children are the pod's own body components (nose cone, body tubes,
401/// transitions), which stack aft from the pod set's position along the pod's axis instead of the
402/// body's.
403///
404/// **Copies.** The pod written in the tree is one pod on the body's axis, repeated `count` times
405/// around it as a rotational pattern: pod `k` is that pod turned by `φ_k = angle + 2π k / count`
406/// about the body's axis and moved to `r (cos φ_k, sin φ_k)` ([`Self::pods`]), in
407/// [body axes](https://github.com/nrdptel/hpr-sim/blob/main/docs/physics/frames.md). Everything
408/// the pod holds (fins on its tubes, parts inside them, a motor in a mount) turns and moves with
409/// it, so what points away from the airframe on one pod does on every pod. Each copy adds its own
410/// parallel-axis term, `I_O = I_cg + m (|d|² E − d dᵀ)` with `d` the copy's center from the point
411/// `O` (J. L. Meriam and L. G. Kraige, *Engineering Mechanics: Dynamics*, appendix B). The pod set
412/// itself weighs nothing: its mass is its pods'. See the design page's *Pods* section
413/// (`docs/physics/design.md`) and the decision record on pods, [ADR-089][adr-089].
414///
415/// **A pod of no length.** A pod may be a single body tube of no length, which weighs nothing: what
416/// hangs from it (fins, a launch lug) sits on a tube of that radius, most often none, so on the
417/// pod's own axis, and is repeated around the body's as any pod is. OpenRocket draws winglets this
418/// way, calling the tube a "phantom body". A pod set may also hold nothing at all, and then weighs
419/// nothing.
420///
421/// **Flown** since
422/// [M1.13c1](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions-and-roadmap.md#m1-13c1):
423/// each pod's parts take their own normal force and drag, once per pod
424/// ([aerodynamics: Pods](https://nrdptel.github.io/hpr-sim/physics/aero.html#pods)).
425///
426/// [adr-089]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-089-a-pod-is-a-stack-of-body-components-repeated-around-the-axis-2026-09-27
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
428#[serde(deny_unknown_fields)]
429pub struct PodSet {
430    /// Number of pods, at least one, spaced evenly around the body's axis.
431    pub count: u32,
432    /// Distance of each pod's axis from the body's axis, m.
433    pub radial_offset_m: f64,
434    /// Roll angle of the first pod from `x_B` toward `y_B`, rad.
435    #[serde(default)]
436    pub angle_rad: f64,
437}
438
439impl PodSet {
440    /// The most pods a set may have. Far more than any rocket carries, it bounds the copies a
441    /// design file can ask for.
442    pub const MAX_COUNT: u32 = 64;
443
444    /// Where each pod sits: pod `k` is the pod as written (on the body's axis) turned by
445    /// `φ_k = angle + 2π k / count` about the axis and moved to `r (cos φ_k, sin φ_k)`, for
446    /// `k = 0 … count − 1`.
447    ///
448    /// # Errors
449    ///
450    /// [`DesignError::Domain`] for no pods or more than [`Self::MAX_COUNT`], a radial offset that
451    /// is negative or not finite, or an angle that is not finite.
452    pub fn pods(&self) -> Result<Vec<Placement>, DesignError> {
453        if self.count == 0 || self.count > Self::MAX_COUNT {
454            return Err(DesignError::Domain {
455                what: "pod count (1 to 64)",
456                value: f64::from(self.count),
457            });
458        }
459        check_dimension("pod radial offset", self.radial_offset_m, true)?;
460        check_angle("pod angle", self.angle_rad)?;
461        let step = std::f64::consts::TAU / f64::from(self.count);
462        Ok((0..self.count)
463            .map(|k| {
464                let phi = self.angle_rad + step * f64::from(k);
465                Placement {
466                    offset_m: [
467                        self.radial_offset_m * phi.cos(),
468                        self.radial_offset_m * phi.sin(),
469                    ],
470                    roll_rad: phi,
471                }
472            })
473            .collect())
474    }
475}
476
477/// A flat annular ring, or a bulkhead when the inner radius is zero.
478#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
479#[serde(deny_unknown_fields)]
480pub struct CenteringRing {
481    /// Thickness along the axis, m.
482    pub length_m: f64,
483    /// Outer radius, m.
484    pub outer_radius_m: f64,
485    /// Inner radius, m (zero for a bulkhead).
486    pub inner_radius_m: f64,
487    /// Material (bulk).
488    pub material: Material,
489}
490
491impl CenteringRing {
492    /// A bulkhead: a solid disc.
493    pub fn bulkhead(length_m: f64, radius_m: f64, material: Material) -> Self {
494        Self {
495            length_m,
496            outer_radius_m: radius_m,
497            inner_radius_m: 0.0,
498            material,
499        }
500    }
501
502    /// Mass properties in the ring's frame.
503    ///
504    /// # Errors
505    ///
506    /// Geometry and material errors, including an inner radius not below the outer.
507    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
508        check_dimension("ring inner radius", self.inner_radius_m, true)?;
509        // A ring whose bore reaches its rim is not a ring: it has no material, and would weigh
510        // nothing without saying so. A *tube* of no wall is a real, massless part and
511        // `hollow_cylinder` allows one; a ring of no annulus is a mistake somewhere upstream.
512        if self.inner_radius_m >= self.outer_radius_m {
513            return Err(DesignError::Geometry(format!(
514                "a centering ring's bore ({} m) reaches its outer radius ({} m), leaving no ring",
515                self.inner_radius_m, self.outer_radius_m
516            )));
517        }
518        let density = self.material.bulk_kg_m3("centering ring")?;
519        hollow_cylinder(
520            "centering ring",
521            density,
522            self.length_m,
523            self.outer_radius_m,
524            self.outer_radius_m - self.inner_radius_m,
525        )
526    }
527}
528
529/// The most copies a row of lugs or rail buttons may have: as [`crate::FinSet::MAX_COUNT`], a
530/// bound on the bodies weighed one per copy, so a design file's count can't stall the weighing.
531pub const MAX_INSTANCES: u32 = 64;
532
533/// Copies of a part spaced along the axis: the first at the part's own position, each next one
534/// `spacing_m` further aft.
535///
536/// # Errors
537///
538/// [`DesignError::Domain`] for a count of none or more than [`MAX_INSTANCES`] (`what` is
539/// `"instance count (1 to 64)"`, the value the count), and a bad spacing.
540fn instances(
541    one: MassProperties,
542    count: u32,
543    spacing_m: f64,
544) -> Result<MassProperties, DesignError> {
545    if count == 0 || count > MAX_INSTANCES {
546        return Err(DesignError::Domain {
547            what: "instance count (1 to 64)",
548            value: f64::from(count),
549        });
550    }
551    check_dimension("instance spacing", spacing_m, true)?;
552    let copies: Vec<MassProperties> = (0..count)
553        .map(|k| one.translated(DVec3::new(0.0, 0.0, -spacing_m * f64::from(k))))
554        .collect();
555    Ok(MassProperties::combine(&copies))
556}
557
558/// A launch lug: a tube on the outside of the airframe, parallel to it.
559#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
560#[serde(deny_unknown_fields)]
561pub struct LaunchLug {
562    /// Length, m.
563    pub length_m: f64,
564    /// Outer radius, m.
565    pub outer_radius_m: f64,
566    /// Wall thickness, m.
567    pub thickness_m: f64,
568    /// Roll angle from `x_B` toward `y_B`, rad.
569    #[serde(default)]
570    pub angle_rad: f64,
571    /// Number of lugs in a row, 1 to 64 ([`MAX_INSTANCES`]).
572    #[serde(default = "one")]
573    pub count: u32,
574    /// Axial distance between the forward ends of consecutive lugs, m.
575    #[serde(default)]
576    pub spacing_m: f64,
577    /// Material (bulk).
578    pub material: Material,
579}
580
581fn one() -> u32 {
582    1
583}
584
585impl LaunchLug {
586    /// Mass properties in the lug's frame, touching a body of radius `body_radius_m`.
587    ///
588    /// # Errors
589    ///
590    /// Geometry and material errors.
591    pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
592        check_dimension("body radius", body_radius_m, true)?;
593        check_angle("launch lug angle", self.angle_rad)?;
594        let density = self.material.bulk_kg_m3("launch lug")?;
595        let tube = hollow_cylinder(
596            "launch lug",
597            density,
598            self.length_m,
599            self.outer_radius_m,
600            self.thickness_m,
601        )?;
602        let placed = tube
603            .translated(DVec3::new(body_radius_m + self.outer_radius_m, 0.0, 0.0))
604            .rolled(self.angle_rad);
605        instances(placed, self.count, self.spacing_m)
606    }
607}
608
609/// A rail button: a base disc on the airframe, a narrower waist, and a flange that rides in the
610/// rail, stacked outward along a radial line, with the screw's dome head on top of the flange.
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
612#[serde(deny_unknown_fields)]
613pub struct RailButton {
614    /// Diameter of the base and the flange, m.
615    pub outer_diameter_m: f64,
616    /// Diameter of the waist, m.
617    pub inner_diameter_m: f64,
618    /// Total height above the airframe, m.
619    pub height_m: f64,
620    /// Height of the base, m.
621    pub base_height_m: f64,
622    /// Height of the flange, m.
623    pub flange_height_m: f64,
624    /// Height of the screw's head above the flange, m: a dome, half an ellipsoid as wide as the
625    /// button, its mass in the button's (0 for none). It has no drag of its own, as OpenRocket
626    /// 24.12 gives it none ([ADR-168][adr-168]).
627    ///
628    /// [adr-168]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0168-pods-powered-flies.md
629    #[serde(default)]
630    pub screw_height_m: f64,
631    /// Roll angle from `x_B` toward `y_B`, rad.
632    #[serde(default)]
633    pub angle_rad: f64,
634    /// Number of buttons in a row, 1 to 64 ([`MAX_INSTANCES`]).
635    #[serde(default = "one")]
636    pub count: u32,
637    /// Axial distance between the forward edges of consecutive buttons, m.
638    #[serde(default)]
639    pub spacing_m: f64,
640    /// Material (bulk).
641    pub material: Material,
642}
643
644impl RailButton {
645    /// Mass properties in the button's frame (origin on the body axis at the button's forward
646    /// edge), on a body of radius `body_radius_m`. Each disc is a solid cylinder whose axis points
647    /// outward. The screw's head is half a solid ellipsoid of revolution on the flange, of the
648    /// button's outer radius `a` and the screw's height `h` along its axis: volume `2/3 π a² h`,
649    /// its center of mass `3h/8` above its base, its moment of inertia `2/5 m a²` about its axis
650    /// and `m (a²/5 + 19 h²/320)` across it through its center of mass (half the ellipsoid's
651    /// `m (a² + h²)/5` about its base's diameter, moved by the parallel-axis theorem). OpenRocket
652    /// 24.12 has the same volume, measured to 2e-16, and puts the head's center `4h/3π` above the
653    /// flange, `0.049 h` further out ([ADR-168][adr-168]).
654    ///
655    /// [adr-168]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0168-pods-powered-flies.md
656    ///
657    /// # Errors
658    ///
659    /// Geometry and material errors, including a base and flange taller than the button or a waist
660    /// wider than the flange.
661    pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
662        check_dimension("body radius", body_radius_m, true)?;
663        check_dimension("rail button outer diameter", self.outer_diameter_m, false)?;
664        check_dimension("rail button inner diameter", self.inner_diameter_m, false)?;
665        check_dimension("rail button height", self.height_m, false)?;
666        check_dimension("rail button base height", self.base_height_m, true)?;
667        check_dimension("rail button flange height", self.flange_height_m, true)?;
668        check_dimension("rail button screw height", self.screw_height_m, true)?;
669        check_angle("rail button angle", self.angle_rad)?;
670        let waist = self.height_m - self.base_height_m - self.flange_height_m;
671        if waist < 0.0 || self.inner_diameter_m > self.outer_diameter_m {
672            return Err(DesignError::Geometry(
673                "a rail button's base and flange must fit in its height, and its waist in its flange"
674                    .to_owned(),
675            ));
676        }
677        let density = self.material.bulk_kg_m3("rail button")?;
678        let center_z = -0.5 * self.outer_diameter_m;
679        let disc = |diameter: f64, height: f64, from: f64| -> MassProperties {
680            let a = 0.5 * diameter;
681            let mass = density * PI * a * a * height;
682            let across = mass * (3.0 * a * a + height * height) / 12.0;
683            MassProperties {
684                mass_kg: mass,
685                cg_m: DVec3::new(body_radius_m + from + 0.5 * height, 0.0, center_z),
686                inertia_kg_m2: hpr_core::DMat3::from_diagonal(DVec3::new(
687                    0.5 * mass * a * a,
688                    across,
689                    across,
690                )),
691            }
692        };
693        let mut stack = vec![
694            disc(self.outer_diameter_m, self.base_height_m, 0.0),
695            disc(self.inner_diameter_m, waist, self.base_height_m),
696            disc(
697                self.outer_diameter_m,
698                self.flange_height_m,
699                self.base_height_m + waist,
700            ),
701        ];
702        // A button with no screw keeps the three discs' sum, bit for bit.
703        if self.screw_height_m > 0.0 {
704            let (a, h) = (0.5 * self.outer_diameter_m, self.screw_height_m);
705            let mass = density * 2.0 / 3.0 * PI * a * a * h;
706            let across = mass * (a * a / 5.0 + 19.0 * h * h / 320.0);
707            stack.push(MassProperties {
708                mass_kg: mass,
709                cg_m: DVec3::new(body_radius_m + self.height_m + 0.375 * h, 0.0, center_z),
710                inertia_kg_m2: hpr_core::DMat3::from_diagonal(DVec3::new(
711                    0.4 * mass * a * a,
712                    across,
713                    across,
714                )),
715            });
716        }
717        let one = MassProperties::combine(&stack).rolled(self.angle_rad);
718        instances(one, self.count, self.spacing_m)
719    }
720}
721
722/// Where and how compactly a mass or a recovery part is stowed: a solid cylinder.
723#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
724#[serde(deny_unknown_fields)]
725pub struct Packing {
726    /// Length, m.
727    pub length_m: f64,
728    /// Radius, m.
729    pub radius_m: f64,
730    /// Distance of its axis from the body axis, m.
731    #[serde(default)]
732    pub radial_offset_m: f64,
733    /// Roll angle of that offset from `x_B` toward `y_B`, rad.
734    #[serde(default)]
735    pub angle_rad: f64,
736}
737
738impl Packing {
739    /// A cylinder of `mass_kg` stowed this way, forward end at the origin.
740    pub(crate) fn place(&self, mass_kg: f64) -> Result<MassProperties, DesignError> {
741        check_dimension("mass", mass_kg, true)?;
742        check_dimension("packed length", self.length_m, true)?;
743        check_dimension("packed radius", self.radius_m, true)?;
744        check_dimension("radial offset", self.radial_offset_m, true)?;
745        check_angle("packing angle", self.angle_rad)?;
746        Ok(solid_cylinder(mass_kg, self.length_m, self.radius_m)
747            .translated(DVec3::new(self.radial_offset_m, 0.0, 0.0))
748            .rolled(self.angle_rad))
749    }
750}
751
752/// A mass of known value: an altimeter bay, ballast, a payload.
753#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
754#[serde(deny_unknown_fields)]
755pub struct MassComponent {
756    /// Mass, kg.
757    pub mass_kg: f64,
758    /// Its extent.
759    pub packing: Packing,
760}
761
762impl MassComponent {
763    /// Mass properties in the component's frame.
764    ///
765    /// # Errors
766    ///
767    /// [`DesignError::Domain`] for a negative or non-finite value.
768    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
769        self.packing.place(self.mass_kg)
770    }
771}
772
773/// A parachute, packed.
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
775#[serde(deny_unknown_fields)]
776pub struct Parachute {
777    /// Nominal (flat) canopy diameter, m.
778    pub diameter_m: f64,
779    /// Canopy fabric (surface).
780    pub canopy_material: Material,
781    /// Number of shroud lines.
782    pub line_count: u32,
783    /// Length of each shroud line, m.
784    pub line_length_m: f64,
785    /// Shroud line (line).
786    pub line_material: Material,
787    /// How it is packed.
788    pub packing: Packing,
789}
790
791impl Parachute {
792    /// Canopy plus lines, kg.
793    ///
794    /// # Errors
795    ///
796    /// Dimension and material errors.
797    pub fn mass_kg(&self) -> Result<f64, DesignError> {
798        check_dimension("parachute diameter", self.diameter_m, true)?;
799        check_dimension("shroud line length", self.line_length_m, true)?;
800        let canopy = self.canopy_material.surface_kg_m2("parachute canopy")?;
801        let line = self.line_material.line_kg_m("shroud line")?;
802        Ok(canopy * PI * self.diameter_m * self.diameter_m / 4.0
803            + f64::from(self.line_count) * self.line_length_m * line)
804    }
805
806    /// Mass properties in the parachute's frame.
807    ///
808    /// # Errors
809    ///
810    /// As [`Parachute::mass_kg`].
811    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
812        self.packing.place(self.mass_kg()?)
813    }
814}
815
816/// A streamer, packed.
817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
818#[serde(deny_unknown_fields)]
819pub struct Streamer {
820    /// Length, m.
821    pub length_m: f64,
822    /// Width, m.
823    pub width_m: f64,
824    /// Material (surface).
825    pub material: Material,
826    /// How it is packed.
827    pub packing: Packing,
828}
829
830impl Streamer {
831    /// Mass properties in the streamer's frame.
832    ///
833    /// # Errors
834    ///
835    /// Dimension and material errors.
836    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
837        check_dimension("streamer length", self.length_m, true)?;
838        check_dimension("streamer width", self.width_m, true)?;
839        let density = self.material.surface_kg_m2("streamer")?;
840        self.packing.place(density * self.length_m * self.width_m)
841    }
842}
843
844/// A shock cord, packed.
845#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
846#[serde(deny_unknown_fields)]
847pub struct ShockCord {
848    /// Length, m.
849    pub length_m: f64,
850    /// Material (line).
851    pub material: Material,
852    /// How it is packed.
853    pub packing: Packing,
854}
855
856impl ShockCord {
857    /// Mass properties in the cord's frame.
858    ///
859    /// # Errors
860    ///
861    /// Dimension and material errors.
862    pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
863        check_dimension("shock cord length", self.length_m, true)?;
864        let density = self.material.line_kg_m("shock cord")?;
865        self.packing.place(density * self.length_m)
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use hpr_core::DMat3;
873
874    fn close(got: f64, want: f64, rel: f64, what: &str) {
875        let err = if want == 0.0 {
876            got.abs()
877        } else {
878            ((got - want) / want).abs()
879        };
880        assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
881    }
882
883    fn cardboard() -> Material {
884        Material::bulk("cardboard", 790.0)
885    }
886
887    #[test]
888    fn a_nose_cone_with_a_capped_shoulder_adds_up_by_hand() {
889        // A conical nose, filled, with a capped shoulder: every piece has a closed form.
890        let (l, r, rho) = (0.2, 0.03, 1240.0);
891        let (ls, rs, ts) = (0.05, 0.028, 0.002);
892        let nose = NoseCone {
893            shape: NoseShape::Conical {},
894            length_m: l,
895            base_radius_m: r,
896            wall: Wall::Filled {},
897            shoulder: Some(Shoulder {
898                length_m: ls,
899                outer_radius_m: rs,
900                thickness_m: ts,
901                capped: true,
902            }),
903            material: Material::bulk("PLA", rho),
904        };
905        let g = nose.mass_properties().unwrap();
906        let cone_m = rho * PI * r * r * l / 3.0;
907        let tube_m = rho * PI * (rs * rs - (rs - ts).powi(2)) * ls;
908        let cap_m = rho * PI * (rs - ts).powi(2) * ts;
909        close(g.mass_kg, cone_m + tube_m + cap_m, 1e-12, "mass");
910        let moment = cone_m * (-0.75 * l) + tube_m * (-l - ls / 2.0) + cap_m * (-l - ls + ts / 2.0);
911        close(
912            g.cg_m.z,
913            moment / (cone_m + tube_m + cap_m),
914            1e-12,
915            "center",
916        );
917        // Axial: cone 3/10 m R², tube m(R² + r²)/2, cap m a²/2.
918        let ri = rs - ts;
919        let axial =
920            0.3 * cone_m * r * r + 0.5 * tube_m * (rs * rs + ri * ri) + 0.5 * cap_m * ri * ri;
921        close(g.inertia_kg_m2.z_axis.z, axial, 1e-11, "axial");
922        // Transverse about the combined center: cone 3/80 m (4R² + L²) about its own center.
923        let zc = g.cg_m.z;
924        let transverse = 3.0 / 80.0 * cone_m * (4.0 * r * r + l * l)
925            + cone_m * (-0.75 * l - zc).powi(2)
926            + tube_m * ((rs * rs + ri * ri) / 4.0 + ls * ls / 12.0)
927            + tube_m * (-l - ls / 2.0 - zc).powi(2)
928            + cap_m * (3.0 * ri * ri + ts * ts) / 12.0
929            + cap_m * (-l - ls + ts / 2.0 - zc).powi(2);
930        close(g.inertia_kg_m2.x_axis.x, transverse, 1e-11, "transverse");
931    }
932
933    #[test]
934    fn shoulders_sit_beyond_the_right_ends() {
935        let shoulder = Shoulder {
936            length_m: 0.04,
937            outer_radius_m: 0.02,
938            thickness_m: 0.001,
939            capped: false,
940        };
941        let t = Transition {
942            shape: NoseShape::Conical {},
943            clipped: false,
944            length_m: 0.1,
945            fore_radius_m: 0.021,
946            aft_radius_m: 0.03,
947            wall: Wall::Shell { thickness_m: 0.002 },
948            fore_shoulder: Some(shoulder),
949            aft_shoulder: None,
950            material: cardboard(),
951        };
952        let fore = shoulder.mass_properties(790.0, 0.0, true).unwrap();
953        close(fore.cg_m.z, 0.02, 1e-15, "fore shoulder center");
954        let aft = shoulder.mass_properties(790.0, -0.1, false).unwrap();
955        close(aft.cg_m.z, -0.12, 1e-15, "aft shoulder center");
956        let whole = t.mass_properties().unwrap();
957        let body = Transition {
958            fore_shoulder: None,
959            ..t.clone()
960        }
961        .mass_properties()
962        .unwrap();
963        close(whole.mass_kg, body.mass_kg + fore.mass_kg, 1e-14, "mass");
964    }
965
966    /// A cluster is its tubes: a 4-ring of tubes 0.02 m from the cluster's axis, the cluster
967    /// itself 0.01 m off the body's, weighs four tubes, has its center on the cluster's axis, and
968    /// about the body's axis its roll inertia is, for each tube, its own plus `m d²` to that tube,
969    /// worked by hand. A tube with no cluster is the one tube, bit for bit; a cluster offset that
970    /// is not finite is refused.
971    #[test]
972    fn a_cluster_is_its_tubes_each_with_its_parallel_axis_term() {
973        let tube = |cluster_m: Vec<[f64; 2]>| InnerTube {
974            length_m: 0.3,
975            outer_radius_m: 0.015,
976            thickness_m: 0.001,
977            radial_offset_m: 0.01,
978            angle_rad: 0.0,
979            material: cardboard(),
980            cluster_m,
981        };
982        let one = hollow_cylinder("t", 790.0, 0.3, 0.015, 0.001).unwrap();
983        let single = tube(Vec::new()).mass_properties().unwrap();
984        assert_eq!(
985            single,
986            one.translated(DVec3::new(0.01, 0.0, 0.0)).rolled(0.0)
987        );
988        assert_eq!(tube(Vec::new()).tubes_m().unwrap(), [[0.0, 0.0]]);
989
990        let square = vec![[0.02, 0.0], [0.0, 0.02], [-0.02, 0.0], [0.0, -0.02]];
991        let g = tube(square.clone()).mass_properties().unwrap();
992        close(g.mass_kg, 4.0 * one.mass_kg, 1e-15, "four tubes");
993        assert!((g.cg_m - DVec3::new(0.01, 0.0, -0.15)).length() < 1e-15);
994        let roll: f64 = square
995            .iter()
996            .map(|[x, y]| {
997                let (dx, dy) = (0.01 + x, *y);
998                one.inertia_kg_m2.z_axis.z + one.mass_kg * (dx * dx + dy * dy)
999            })
1000            .sum();
1001        close(
1002            g.inertia_about(DVec3::new(0.0, 0.0, -0.15)).z_axis.z,
1003            roll,
1004            1e-15,
1005            "roll about the body axis",
1006        );
1007
1008        let Err(DesignError::Domain { what, value }) =
1009            tube(vec![[f64::NAN, 0.0]]).mass_properties()
1010        else {
1011            panic!("refused");
1012        };
1013        assert_eq!(what, "inner tube cluster offset (m)");
1014        assert!(value.is_nan());
1015    }
1016
1017    #[test]
1018    fn off_axis_parts_carry_their_offsets() {
1019        // An inner tube in a cluster, a lug and a mass component, each against the parallel-axis
1020        // theorem.
1021        let tube = InnerTube {
1022            length_m: 0.3,
1023            outer_radius_m: 0.015,
1024            thickness_m: 0.001,
1025            radial_offset_m: 0.03,
1026            angle_rad: PI / 2.0,
1027            material: cardboard(),
1028            cluster_m: Vec::new(),
1029        };
1030        let g = tube.mass_properties().unwrap();
1031        assert!((g.cg_m - DVec3::new(0.0, 0.03, -0.15)).length() < 1e-15);
1032        let own = hollow_cylinder("t", 790.0, 0.3, 0.015, 0.001).unwrap();
1033        // About the body axis, the axial moment gains m d².
1034        close(
1035            g.inertia_about(DVec3::new(0.0, 0.0, -0.15)).z_axis.z,
1036            own.inertia_kg_m2.z_axis.z + own.mass_kg * 0.03 * 0.03,
1037            1e-13,
1038            "axial about the body axis",
1039        );
1040
1041        let lug = LaunchLug {
1042            length_m: 0.05,
1043            outer_radius_m: 0.003,
1044            thickness_m: 0.0005,
1045            angle_rad: 0.0,
1046            count: 2,
1047            spacing_m: 0.4,
1048            material: Material::bulk("brass", 8500.0),
1049        };
1050        let g = lug.mass_properties(0.02).unwrap();
1051        let one = hollow_cylinder("l", 8500.0, 0.05, 0.003, 0.0005).unwrap();
1052        close(g.mass_kg, 2.0 * one.mass_kg, 1e-14, "two lugs");
1053        assert!((g.cg_m - DVec3::new(0.023, 0.0, -0.225)).length() < 1e-15);
1054        close(
1055            g.inertia_kg_m2.x_axis.x,
1056            one.inertia_kg_m2.x_axis.x * 2.0 + 2.0 * one.mass_kg * 0.2 * 0.2,
1057            1e-13,
1058            "two lugs transverse",
1059        );
1060
1061        let mass = MassComponent {
1062            mass_kg: 0.25,
1063            packing: Packing {
1064                length_m: 0.1,
1065                radius_m: 0.02,
1066                radial_offset_m: 0.01,
1067                angle_rad: PI,
1068            },
1069        };
1070        let g = mass.mass_properties().unwrap();
1071        assert!((g.cg_m - DVec3::new(-0.01, 0.0, -0.05)).length() < 1e-15);
1072        let expected = DMat3::from_diagonal(DVec3::new(
1073            0.25 * (3.0 * 0.0004 + 0.01) / 12.0,
1074            0.25 * (3.0 * 0.0004 + 0.01) / 12.0,
1075            0.25 * 0.0004 / 2.0,
1076        ));
1077        let diff = (g.inertia_kg_m2 - expected)
1078            .to_cols_array()
1079            .iter()
1080            .fold(0.0f64, |m, v| m.max(v.abs()));
1081        assert!(diff < 1e-18, "{g:?}");
1082    }
1083
1084    #[test]
1085    fn a_lug_or_button_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1086        let lug = |count| LaunchLug {
1087            length_m: 0.05,
1088            outer_radius_m: 0.003,
1089            thickness_m: 0.0005,
1090            angle_rad: 0.0,
1091            count,
1092            spacing_m: 0.01,
1093            material: Material::bulk("brass", 8500.0),
1094        };
1095        let button = |count| RailButton {
1096            outer_diameter_m: 0.0111,
1097            inner_diameter_m: 0.0063,
1098            height_m: 0.0086,
1099            base_height_m: 0.0025,
1100            flange_height_m: 0.0022,
1101            screw_height_m: 0.0,
1102            angle_rad: 0.0,
1103            count,
1104            spacing_m: 0.01,
1105            material: Material::bulk("acetal", 1420.0),
1106        };
1107        // A count past the bound used to build one body per copy: four billion stalled.
1108        for count in [0, MAX_INSTANCES + 1, 4_000_000_000] {
1109            for result in [
1110                lug(count).mass_properties(0.02),
1111                button(count).mass_properties(0.02),
1112            ] {
1113                match result {
1114                    Err(DesignError::Domain { what, value }) => {
1115                        assert_eq!(what, "instance count (1 to 64)");
1116                        assert_eq!(value, f64::from(count));
1117                    }
1118                    other => panic!("count {count}: {other:?}"),
1119                }
1120            }
1121        }
1122        let one = lug(1).mass_properties(0.02).unwrap().mass_kg;
1123        let all = lug(MAX_INSTANCES).mass_properties(0.02).unwrap().mass_kg;
1124        close(all, 64.0 * one, 1e-13, "64 lugs");
1125        let one = button(1).mass_properties(0.02).unwrap().mass_kg;
1126        let all = button(MAX_INSTANCES).mass_properties(0.02).unwrap().mass_kg;
1127        close(all, 64.0 * one, 1e-13, "64 buttons");
1128    }
1129
1130    /// A rail button's screw head (ADR-168): half an ellipsoid on the flange, as wide as the
1131    /// button. OpenRocket 24.12's probes (a 25 mm body, a 10 mm button 8 mm tall on 2 mm discs, a
1132    /// 6 mm waist, 1000 kg/m³) give the button's volume for screws of 0, 1, 2 and 5 mm; its `2/3`
1133    /// is a single-precision number, hpr's the double, and that is the only difference (1.1e-8 of
1134    /// the volume at 5 mm). OpenRocket puts the head's center
1135    /// `4h/3π` above the flange, hpr at the solid's `3h/8`: the button's center differs by that
1136    /// alone, weighted by the head's share of the volume.
1137    #[test]
1138    fn a_rail_buttons_screw_head_is_half_an_ellipsoid() {
1139        let button = |screw_height_m: f64| RailButton {
1140            outer_diameter_m: 0.010,
1141            inner_diameter_m: 0.006,
1142            height_m: 0.008,
1143            base_height_m: 0.002,
1144            flange_height_m: 0.002,
1145            screw_height_m,
1146            angle_rad: 0.0,
1147            count: 1,
1148            spacing_m: 0.0,
1149            material: Material::bulk("probe", 1000.0),
1150        };
1151        let openrocket_m3 = [
1152            (0.0, 4.272566008882119e-07),
1153            (0.001, 4.796164800084878e-07),
1154            (0.002, 5.319763591287636e-07),
1155            (0.005, 6.890559964895911e-07),
1156        ];
1157        // OpenRocket's `2/3`, in single precision: the volumes differ by that alone.
1158        let openrocket_two_thirds = f64::from(2.0_f32 / 3.0_f32);
1159        for (screw_m, volume_m3) in openrocket_m3 {
1160            let mass = button(screw_m).mass_properties(0.025).unwrap();
1161            let head_gap_m3 = (2.0 / 3.0 - openrocket_two_thirds)
1162                * std::f64::consts::PI
1163                * 0.005
1164                * 0.005
1165                * screw_m;
1166            close(
1167                mass.mass_kg / 1000.0,
1168                volume_m3 + head_gap_m3,
1169                1e-14,
1170                "volume",
1171            );
1172        }
1173        // No screw is the three discs bit for bit.
1174        assert_eq!(
1175            button(0.0).mass_properties(0.025).unwrap(),
1176            RailButton {
1177                screw_height_m: 0.0,
1178                ..button(0.0)
1179            }
1180            .mass_properties(0.025)
1181            .unwrap()
1182        );
1183        // The center, from the discs' (no screw) and the head's: hpr's head at `3h/8` above the
1184        // flange, 33 mm from the axis; OpenRocket's at `4h/3π`, with its own volume, which gives
1185        // its measured 0.029954493401421 m at 2 mm.
1186        let (h, a) = (0.002, 0.005);
1187        let discs = button(0.0).mass_properties(0.025).unwrap();
1188        let discs_m3 = discs.mass_kg / 1000.0;
1189        let center = |two_thirds: f64, above: f64| {
1190            let head_m3 = two_thirds * std::f64::consts::PI * a * a * h;
1191            (discs_m3 * discs.cg_m.x + head_m3 * (0.033 + above * h)) / (discs_m3 + head_m3)
1192        };
1193        let openrocket = center(openrocket_two_thirds, 4.0 / (3.0 * std::f64::consts::PI));
1194        close(
1195            openrocket,
1196            0.029954493401421,
1197            1e-12,
1198            "OpenRocket's radial center",
1199        );
1200        let screwed = button(h).mass_properties(0.025).unwrap();
1201        close(
1202            screwed.cg_m.x,
1203            center(2.0 / 3.0, 0.375),
1204            1e-12,
1205            "radial center",
1206        );
1207        // Alone, the head of a screw as tall as the button is wide is a hemisphere: its inertia
1208        // about its center across its axis is 83/320 m a², and 2/5 m a² about its axis.
1209        let head = RailButton {
1210            outer_diameter_m: 2.0 * h,
1211            inner_diameter_m: 1e-12,
1212            height_m: 1e-12,
1213            base_height_m: 0.0,
1214            flange_height_m: 0.0,
1215            ..button(h)
1216        }
1217        .mass_properties(0.025)
1218        .unwrap();
1219        let m = head.mass_kg;
1220        close(
1221            m,
1222            1000.0 * 2.0 / 3.0 * std::f64::consts::PI * h * h * h,
1223            1e-9,
1224            "mass",
1225        );
1226        close(
1227            head.inertia_kg_m2.x_axis.x,
1228            0.4 * m * h * h,
1229            1e-9,
1230            "about its axis",
1231        );
1232        close(
1233            head.inertia_kg_m2.y_axis.y,
1234            83.0 / 320.0 * m * h * h,
1235            1e-9,
1236            "across",
1237        );
1238        close(
1239            head.inertia_kg_m2.z_axis.z,
1240            83.0 / 320.0 * m * h * h,
1241            1e-9,
1242            "across",
1243        );
1244        close(head.cg_m.x, 0.025 + 1e-12 + 0.375 * h, 1e-12, "its center");
1245        // A screw of negative height is refused.
1246        assert!(button(-1e-3).mass_properties(0.025).is_err());
1247    }
1248
1249    #[test]
1250    fn recovery_parts_weigh_their_fabric_and_lines() {
1251        let chute = Parachute {
1252            diameter_m: 0.9,
1253            canopy_material: Material::surface("ripstop nylon", 0.0373),
1254            line_count: 8,
1255            line_length_m: 0.9,
1256            line_material: Material::line("nylon line", 0.0016),
1257            packing: Packing {
1258                length_m: 0.1,
1259                radius_m: 0.03,
1260                radial_offset_m: 0.0,
1261                angle_rad: 0.0,
1262            },
1263        };
1264        let canopy = 0.0373 * PI * 0.81 / 4.0;
1265        let lines = 8.0 * 0.9 * 0.0016;
1266        close(chute.mass_kg().unwrap(), canopy + lines, 1e-15, "parachute");
1267        let streamer = Streamer {
1268            length_m: 1.5,
1269            width_m: 0.1,
1270            material: Material::surface("mylar", 0.0353),
1271            packing: chute.packing,
1272        };
1273        close(
1274            streamer.mass_properties().unwrap().mass_kg,
1275            1.5 * 0.1 * 0.0353,
1276            1e-15,
1277            "streamer",
1278        );
1279        let cord = ShockCord {
1280            length_m: 6.0,
1281            material: Material::line("Kevlar", 0.00968),
1282            packing: chute.packing,
1283        };
1284        let g = cord.mass_properties().unwrap();
1285        close(g.mass_kg, 6.0 * 0.00968, 1e-15, "cord");
1286        close(
1287            g.inertia_kg_m2.z_axis.z,
1288            0.5 * g.mass_kg * 0.03 * 0.03,
1289            1e-15,
1290            "cord axial",
1291        );
1292        let wrong = ShockCord {
1293            material: cardboard(),
1294            ..cord
1295        };
1296        assert!(matches!(
1297            wrong.mass_properties(),
1298            Err(DesignError::MaterialKind { .. })
1299        ));
1300    }
1301}
1302
1303#[cfg(test)]
1304mod zero_wall_tests {
1305    use super::*;
1306
1307    /// A tube whose inner radius reaches its outer one has no wall, and so no mass and no inertia.
1308    /// A design file can say that, and refusing it would leave a reader inventing a wall instead
1309    /// (`docs/physics/mass.md`). A **ring** is the exception: a bore that reaches the rim leaves no
1310    /// ring, which is a mistake upstream rather than a part, so it is refused loudly.
1311    #[test]
1312    fn a_tube_of_no_wall_weighs_nothing_and_a_ring_of_no_annulus_is_refused() {
1313        let tube = InnerTube {
1314            length_m: 0.18,
1315            outer_radius_m: 0.025,
1316            thickness_m: 0.0,
1317            radial_offset_m: 0.0,
1318            angle_rad: 0.0,
1319            material: Material::bulk("cardboard", 680.0),
1320            cluster_m: Vec::new(),
1321        };
1322        let mass = tube.mass_properties().expect("a tube of no wall");
1323        assert_eq!(mass.mass_kg, 0.0);
1324        assert_eq!(mass.inertia_kg_m2, hpr_core::DMat3::ZERO);
1325        mass.validate().expect("a valid body");
1326
1327        // The same tube with a wall weighs what the annulus weighs, so the limit is the formula's.
1328        let walled = InnerTube {
1329            thickness_m: 1e-9,
1330            ..tube.clone()
1331        };
1332        let a_little = walled.mass_properties().expect("a thin wall").mass_kg;
1333        assert!(a_little > 0.0 && a_little < 1e-7, "{a_little}");
1334
1335        let ring = CenteringRing {
1336            length_m: 0.005,
1337            outer_radius_m: 0.0115,
1338            inner_radius_m: 0.0115,
1339            material: Material::bulk("plywood", 630.0),
1340        };
1341        let refused = ring.mass_properties().expect_err("a ring of no annulus");
1342        assert!(refused.to_string().contains("leaving no ring"), "{refused}");
1343    }
1344}