Skip to main content

hpr_design/
tree.rs

1//! The design tree: a rocket of stages, each a stack of body components from the nose aft with
2//! parts attached to them, and how the tree resolves into placed parts and the rocket's structural
3//! mass properties.
4//!
5//! **Stations and the body origin.** A *station* `s` is a distance aft of the nose tip, the way
6//! design files state positions. The body frame's origin is the nose tip (`z_ref = 0` in
7//! `docs/physics/frames.md`), so station `s` is body `z = −s`. A part's own frame has its origin at
8//! its forward end ([`crate::mass`]), so a part whose forward end is at station `s` is translated by
9//! `(0, 0, −s)`.
10//!
11//! **Body components** (nose cones, body tubes and transitions) are the stages' component lists.
12//! They stack: each starts where the one before it ends, through every stage, from `s = 0`.
13//! Shoulders don't count toward the stack.
14//!
15//! **Parallel stages** ([`ParallelStage`]) don't stack: a stage strapped beside a body tube of an
16//! axial stage, such as a set of boosters, is laid out as a pod set on that tube ([`PodSet`]), its
17//! body components each pod's stack, while its parts and their mass stay its own stage's, so a
18//! separation after the stage it hangs on drops it (the decision record on parallel stages,
19//! [ADR-171][adr-171]).
20//!
21//! **Attached parts** are children of a component, placed along it by a [`Position`]. Fins, tube
22//! fins, launch lugs and rail buttons attach to the outside of a body tube and take its outer
23//! radius. Inner tubes, centering rings, mass components and recovery parts go inside a body
24//! component or an inner tube. Only inner tubes have children of their own. Radial offsets are
25//! always measured from the body axis.
26//!
27//! **Automatic radii** ([`AutoDimension`]) are taken from neighbours and parents when the tree
28//! resolves; a stored value for an automatic dimension is ignored.
29//!
30//! **Mass.** A component's own mass properties come from its part's geometry, placed. Overrides
31//! ([`Overrides`]) replace them, or the component together with everything attached to it; a
32//! stage's overrides replace the whole stage. Overrides nested deeper apply first. Motors are never
33//! part of the structure ([`crate::config`]).
34//!
35//! See `docs/physics/design.md` and the decision record on the design tree, [ADR-007][adr-007].
36//!
37//! [adr-007]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-007-design-tree-stations-placement-automatic-radii-overrides-motors-and-checks-2026-09-17
38//! [adr-171]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-171-a-parallel-stage-is-a-pod-set-that-separates-2026-10-05
39
40use std::collections::{BTreeMap, BTreeSet};
41
42use hpr_core::{DMat3, DVec3};
43use serde::{Deserialize, Serialize};
44
45use crate::config::{Configuration, MotorMount};
46use crate::error::DesignError;
47use crate::finish::Finish;
48use crate::fins::{FinSet, TubeFinSet};
49use crate::mass::{MassProperties, Placement};
50use crate::parts::{
51    BodyTube, CenteringRing, InnerTube, LaunchLug, MassComponent, NoseCone, Packing, Parachute,
52    PodSet, RailButton, ShockCord, Streamer, Transition,
53};
54use crate::shapes::check_dimension;
55use crate::solids::Wall;
56
57/// Slack when comparing lengths that should agree, m: far below any build tolerance and far above
58/// the round-off in stations summed from millimeter inputs.
59pub const LENGTH_TOLERANCE_M: f64 = 1e-9;
60
61/// A rocket design: its stages, how its reference diameter is chosen, and its motor
62/// configurations.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(deny_unknown_fields)]
65pub struct Rocket {
66    /// Name.
67    #[serde(default)]
68    pub name: String,
69    /// Stages from the nose aft. The first holds the nose cone. A parallel stage comes after the
70    /// stage it hangs on ([`Stage::parallel`]).
71    pub stages: Vec<Stage>,
72    /// How the reference diameter is chosen.
73    #[serde(default)]
74    pub reference_diameter: ReferenceDiameter,
75    /// Motor configurations.
76    #[serde(default)]
77    pub configurations: Vec<Configuration>,
78}
79
80/// A stage: body components stacked from its forward end aft, on the airframe's axis or, for a
81/// parallel stage, along each of its pods.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct Stage {
85    /// Unique id.
86    pub id: String,
87    /// Name.
88    #[serde(default)]
89    pub name: String,
90    /// Body components (nose cones, body tubes, transitions), forward to aft.
91    pub components: Vec<Component>,
92    /// Overrides for the whole stage without its motors, applied after every override inside it. A
93    /// center-of-mass override is measured aft of the stage's forward end.
94    #[serde(
95        default,
96        skip_serializing_if = "Overrides::is_empty",
97        deserialize_with = "object_only"
98    )]
99    pub overrides: Overrides,
100    /// A drag coefficient stated for the stage ([`DragOverride`]): added to the rocket's drag, or,
101    /// covering its children, in place of everything in the stage.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub drag_override: Option<DragOverride>,
104    /// Where the stage hangs beside the airframe, for a parallel stage; `None` for a stage on the
105    /// axis, which stacks.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub parallel: Option<ParallelStage>,
108}
109
110/// Where a parallel stage hangs: beside a body tube of an earlier axial stage, as a set of pods.
111///
112/// A parallel stage, such as a pair of strap-on boosters, is laid out exactly as a pod set on that
113/// tube would be ([`PodSet`]): its body components are each pod's stack, repeated `pods.count`
114/// times around the axis, and they take their normal force and drag once per pod. What differs is
115/// whose they are: the stage's parts, mass and motors are its own stage's, not the tube's, so a
116/// separation after the stage it hangs on drops the pods while the airframe flies on. See the
117/// design page's *Parallel stages* section (`docs/physics/design.md`) and the decision record on
118/// parallel stages, [ADR-171][adr-171].
119///
120/// [adr-171]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-171-a-parallel-stage-is-a-pod-set-that-separates-2026-10-05
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
122#[serde(deny_unknown_fields)]
123pub struct ParallelStage {
124    /// The id of the body tube it hangs on: a body component of an earlier stage on the axis.
125    pub on: String,
126    /// Where along that tube the pods sit, as an attached part's position; measured from the
127    /// tube, never after a sibling ([`Position::After`] is refused).
128    pub position: Position,
129    /// How many pods, how far from the axis, and at what roll angle.
130    pub pods: PodSet,
131}
132
133/// Reads a struct from an object only. serde's derived reader also takes a struct from an array
134/// of its fields in order, so `"overrides": []` would read as no overrides and be dropped on
135/// writing, while the schema refuses it (issue #253); the format reader's array check can't see
136/// a key the written document leaves out.
137fn object_only<'de, D, T>(deserializer: D) -> Result<T, D::Error>
138where
139    D: serde::Deserializer<'de>,
140    T: Deserialize<'de>,
141{
142    struct ObjectOnly<T>(std::marker::PhantomData<T>);
143    impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectOnly<T> {
144        type Value = T;
145        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
146            formatter.write_str("an object")
147        }
148        fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
149            T::deserialize(serde::de::value::MapAccessDeserializer::new(map))
150        }
151    }
152    deserializer.deserialize_map(ObjectOnly(std::marker::PhantomData))
153}
154
155/// A node of the design tree: a part, where it sits, and what hangs off it.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
157#[serde(deny_unknown_fields)]
158pub struct Component {
159    /// Unique id.
160    pub id: String,
161    /// Name.
162    #[serde(default)]
163    pub name: String,
164    /// The part, with its geometry and material.
165    pub part: Part,
166    /// Where an attached part sits along its parent. Body components (a stage's own list) have
167    /// none: they stack.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub position: Option<Position>,
170    /// Dimensions taken from neighbours or the parent instead of the part's stored values.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub auto: Vec<AutoDimension>,
173    /// Makes a body tube or an inner tube a motor mount.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub motor_mount: Option<MotorMount>,
176    /// The outer surface's finish, for skin friction; `None` means [`Finish::default`]. Parts
177    /// inside the body ignore it.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub finish: Option<Finish>,
180    /// Mass, center-of-mass and inertia overrides.
181    #[serde(
182        default,
183        skip_serializing_if = "Overrides::is_empty",
184        deserialize_with = "object_only"
185    )]
186    pub overrides: Overrides,
187    /// Whether the overrides replace this component together with everything attached to it
188    /// (`true`), or this component alone (`false`).
189    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
190    pub overrides_include_children: bool,
191    /// A drag coefficient stated in place of the drag the component's geometry gives
192    /// ([`DragOverride`]).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub drag_override: Option<DragOverride>,
195    /// Attached parts, or a pod set's body components.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub children: Vec<Component>,
198}
199
200impl Component {
201    /// The axial extent used to place the component, m: [`Part::length_m`], but for a pod set the
202    /// sum of its body components' lengths, which stack along the pod.
203    pub fn length_m(&self) -> f64 {
204        match self.part {
205            // A fold from +0: an empty sum of floats is −0.
206            Part::PodSet(_) => self
207                .children
208                .iter()
209                .fold(0.0, |length, c| length + c.part.length_m()),
210            _ => self.part.length_m(),
211        }
212    }
213}
214
215/// A part in the tree. Serialized as an object with one key, the part's kind.
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
217#[serde(rename_all = "snake_case")]
218#[non_exhaustive]
219pub enum Part {
220    /// A nose cone (body component).
221    NoseCone(NoseCone),
222    /// A body tube (body component).
223    BodyTube(BodyTube),
224    /// A transition (body component).
225    Transition(Transition),
226    /// An inner tube (internal): a coupler, motor mount tube or engine block.
227    InnerTube(InnerTube),
228    /// A centering ring or bulkhead (internal).
229    CenteringRing(CenteringRing),
230    /// A fin set (external, on a body tube). Its axial extent is the root chord.
231    FinSet(FinSet),
232    /// Tube fins (external, on a body tube).
233    TubeFinSet(TubeFinSet),
234    /// Launch lugs (external, on a body tube). The extent covers the whole row.
235    LaunchLug(LaunchLug),
236    /// Rail buttons (external, on a body tube). The extent covers the whole row.
237    RailButton(RailButton),
238    /// Pods (external, on a body tube). Its children are the pod's body components, which stack
239    /// along the pod's axis; its extent is theirs ([`Component::length_m`]).
240    PodSet(PodSet),
241    /// A mass component (internal).
242    MassComponent(MassComponent),
243    /// A parachute (internal).
244    Parachute(Parachute),
245    /// A streamer (internal).
246    Streamer(Streamer),
247    /// A shock cord (internal).
248    ShockCord(ShockCord),
249}
250
251/// Where a part belongs in the tree.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253enum Role {
254    /// A stage's stacked component.
255    Body,
256    /// On the outside of a body tube.
257    External,
258    /// Inside a body component or an inner tube.
259    Internal,
260}
261
262impl Part {
263    /// The part's kind, as serialized.
264    pub fn kind_name(&self) -> &'static str {
265        match self {
266            Self::NoseCone(_) => "nose_cone",
267            Self::BodyTube(_) => "body_tube",
268            Self::Transition(_) => "transition",
269            Self::InnerTube(_) => "inner_tube",
270            Self::CenteringRing(_) => "centering_ring",
271            Self::FinSet(_) => "fin_set",
272            Self::TubeFinSet(_) => "tube_fin_set",
273            Self::LaunchLug(_) => "launch_lug",
274            Self::RailButton(_) => "rail_button",
275            Self::PodSet(_) => "pod_set",
276            Self::MassComponent(_) => "mass_component",
277            Self::Parachute(_) => "parachute",
278            Self::Streamer(_) => "streamer",
279            Self::ShockCord(_) => "shock_cord",
280        }
281    }
282
283    fn role(&self) -> Role {
284        match self {
285            Self::NoseCone(_) | Self::BodyTube(_) | Self::Transition(_) => Role::Body,
286            Self::FinSet(_)
287            | Self::TubeFinSet(_)
288            | Self::LaunchLug(_)
289            | Self::RailButton(_)
290            | Self::PodSet(_) => Role::External,
291            Self::InnerTube(_)
292            | Self::CenteringRing(_)
293            | Self::MassComponent(_)
294            | Self::Parachute(_)
295            | Self::Streamer(_)
296            | Self::ShockCord(_) => Role::Internal,
297        }
298    }
299
300    /// Whether this is a body component: a nose cone, body tube or transition.
301    pub fn is_body(&self) -> bool {
302        self.role() == Role::Body
303    }
304
305    /// Whether this attaches to the outside of a body tube: fins, tube fins, lugs, rail buttons,
306    /// pods.
307    pub fn is_external(&self) -> bool {
308        self.role() == Role::External
309    }
310
311    /// The axial extent used to place the part, m: a body component's length without shoulders, a
312    /// fin set's root chord, a row of lugs or buttons from the first one's forward end to the last
313    /// one's aft end, and a packed part's packed length. A pod set's extent is its pods' body
314    /// components, which the part alone doesn't hold, so here it is 0: see
315    /// [`Component::length_m`].
316    pub fn length_m(&self) -> f64 {
317        let row =
318            |count: u32, one: f64, spacing: f64| one + spacing * f64::from(count.saturating_sub(1));
319        match self {
320            Self::NoseCone(p) => p.length_m,
321            Self::BodyTube(p) => p.length_m,
322            Self::Transition(p) => p.length_m,
323            Self::InnerTube(p) => p.length_m,
324            Self::CenteringRing(p) => p.length_m,
325            Self::FinSet(p) => p.planform.root_chord_m(),
326            Self::TubeFinSet(p) => p.length_m,
327            Self::LaunchLug(p) => row(p.count, p.length_m, p.spacing_m),
328            Self::RailButton(p) => row(p.count, p.outer_diameter_m, p.spacing_m),
329            Self::PodSet(_) => 0.0,
330            Self::MassComponent(p) => p.packing.length_m,
331            Self::Parachute(p) => p.packing.length_m,
332            Self::Streamer(p) => p.packing.length_m,
333            Self::ShockCord(p) => p.packing.length_m,
334        }
335    }
336
337    /// How a mass object or recovery part is packed, or `None` for a part that is not packed.
338    pub fn packing(&self) -> Option<&Packing> {
339        match self {
340            Self::MassComponent(p) => Some(&p.packing),
341            Self::Parachute(p) => Some(&p.packing),
342            Self::Streamer(p) => Some(&p.packing),
343            Self::ShockCord(p) => Some(&p.packing),
344            _ => None,
345        }
346    }
347
348    /// A body component's outer radius at its forward end, m (zero at a nose tip).
349    pub fn fore_radius_m(&self) -> Option<f64> {
350        match self {
351            Self::NoseCone(_) => Some(0.0),
352            Self::BodyTube(p) => Some(p.outer_radius_m),
353            Self::Transition(p) => Some(p.fore_radius_m),
354            _ => None,
355        }
356    }
357
358    /// A body component's outer radius at its aft end, m.
359    pub fn aft_radius_m(&self) -> Option<f64> {
360        match self {
361            Self::NoseCone(p) => Some(p.base_radius_m),
362            Self::BodyTube(p) => Some(p.outer_radius_m),
363            Self::Transition(p) => Some(p.aft_radius_m),
364            _ => None,
365        }
366    }
367
368    /// A body component's largest outer radius anywhere along it, m.
369    ///
370    /// # Errors
371    ///
372    /// Profile errors for a nose cone or transition.
373    pub fn max_radius_m(&self) -> Result<Option<f64>, DesignError> {
374        Ok(match self {
375            Self::NoseCone(p) => Some(p.profile()?.max_radius_m()),
376            Self::BodyTube(p) => Some(p.outer_radius_m),
377            Self::Transition(p) => Some(p.profile()?.max_radius_m()),
378            _ => None,
379        })
380    }
381
382    /// The inside radius of a body tube or inner tube, m: where internal parts fit.
383    pub fn inner_radius_m(&self) -> Option<f64> {
384        match self {
385            Self::BodyTube(p) => Some(p.outer_radius_m - p.thickness_m),
386            Self::InnerTube(p) => Some(p.outer_radius_m - p.thickness_m),
387            _ => None,
388        }
389    }
390
391    /// Where the part's own axis crosses the `x`–`y` plane, `[x, y]` in body axes, m: an inner
392    /// tube's or packed part's radial offset turned by its angle, and the body axis for every
393    /// other part.
394    pub fn axis_offset_m(&self) -> [f64; 2] {
395        let turned = |r: f64, angle: f64| [r * angle.cos(), r * angle.sin()];
396        match self {
397            Self::InnerTube(p) => turned(p.radial_offset_m, p.angle_rad),
398            Self::MassComponent(p) => turned(p.packing.radial_offset_m, p.packing.angle_rad),
399            Self::Parachute(p) => turned(p.packing.radial_offset_m, p.packing.angle_rad),
400            Self::Streamer(p) => turned(p.packing.radial_offset_m, p.packing.angle_rad),
401            Self::ShockCord(p) => turned(p.packing.radial_offset_m, p.packing.angle_rad),
402            _ => [0.0, 0.0],
403        }
404    }
405
406    /// An internal part's outer radius about its own axis, m: an inner tube's or ring's outer
407    /// radius, or a packed part's packed radius.
408    pub fn outer_radius_about_axis_m(&self) -> Option<f64> {
409        match self {
410            Self::InnerTube(p) => Some(p.outer_radius_m),
411            Self::CenteringRing(p) => Some(p.outer_radius_m),
412            Self::MassComponent(p) => Some(p.packing.radius_m),
413            Self::Parachute(p) => Some(p.packing.radius_m),
414            Self::Streamer(p) => Some(p.packing.radius_m),
415            Self::ShockCord(p) => Some(p.packing.radius_m),
416            _ => None,
417        }
418    }
419
420    /// How far an internal part reaches from `axis` (`[x, y]` in body axes, m): the distance
421    /// between the two axes plus the part's outer radius, for the farthest tube of a cluster.
422    pub fn reach_from_m(&self, axis: [f64; 2]) -> Option<f64> {
423        let [x, y] = self.axis_offset_m();
424        let tubes = match self {
425            Self::InnerTube(tube) if !tube.cluster_m.is_empty() => tube.cluster_m.as_slice(),
426            _ => &[[0.0, 0.0]],
427        };
428        self.outer_radius_about_axis_m().map(|r| {
429            tubes
430                .iter()
431                .map(|&[u, v]| (x + u - axis[0]).hypot(y + v - axis[1]) + r)
432                .fold(f64::NEG_INFINITY, f64::max)
433        })
434    }
435
436    /// Mass properties in the part's own frame. External attachments need the radius of the body
437    /// they sit on.
438    ///
439    /// # Errors
440    ///
441    /// The part's own geometry, material and numerical errors, and [`DesignError::Geometry`] when
442    /// an external attachment has no body radius.
443    pub fn mass_properties(
444        &self,
445        body_radius_m: Option<f64>,
446    ) -> Result<MassProperties, DesignError> {
447        let body = || {
448            body_radius_m.ok_or_else(|| {
449                DesignError::Geometry(format!(
450                    "a {} needs the radius of the body it sits on",
451                    self.kind_name()
452                ))
453            })
454        };
455        match self {
456            Self::NoseCone(p) => p.mass_properties(),
457            Self::BodyTube(p) => p.mass_properties(),
458            Self::Transition(p) => p.mass_properties(),
459            Self::InnerTube(p) => p.mass_properties(),
460            Self::CenteringRing(p) => p.mass_properties(),
461            Self::FinSet(p) => p.mass_properties(body()?),
462            Self::TubeFinSet(p) => p.mass_properties(body()?),
463            Self::LaunchLug(p) => p.mass_properties(body()?),
464            Self::RailButton(p) => p.mass_properties(body()?),
465            Self::PodSet(p) => p.pods().map(|_| MassProperties::ZERO),
466            Self::MassComponent(p) => p.mass_properties(),
467            Self::Parachute(p) => p.mass_properties(),
468            Self::Streamer(p) => p.mass_properties(),
469            Self::ShockCord(p) => p.mass_properties(),
470        }
471    }
472}
473
474/// Where an attached part sits along its parent. Offsets are positive aft.
475#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
476#[serde(tag = "from", rename_all = "snake_case", deny_unknown_fields)]
477pub enum Position {
478    /// The part's forward end is `aft_offset_m` aft of the parent's forward end.
479    Top {
480        /// Offset, m.
481        #[serde(default)]
482        aft_offset_m: f64,
483    },
484    /// The part's middle is `aft_offset_m` aft of the parent's middle.
485    Middle {
486        /// Offset, m.
487        #[serde(default)]
488        aft_offset_m: f64,
489    },
490    /// The part's aft end is `aft_offset_m` aft of the parent's aft end.
491    Bottom {
492        /// Offset, m.
493        #[serde(default)]
494        aft_offset_m: f64,
495    },
496    /// The part's forward end is `aft_offset_m` aft of the previous sibling's aft end, or of the
497    /// parent's forward end for the first child.
498    After {
499        /// Offset, m.
500        #[serde(default)]
501        aft_offset_m: f64,
502    },
503    /// The part's forward end is at station `station_m`, measured aft of the nose tip.
504    Absolute {
505        /// Station, m.
506        station_m: f64,
507    },
508}
509
510/// A dimension resolved from the tree instead of stored in the part.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
512#[serde(rename_all = "snake_case")]
513#[non_exhaustive]
514pub enum AutoDimension {
515    /// A nose cone's base radius: the next body component's forward radius.
516    BaseRadius,
517    /// A body tube's outer radius: the previous body component's aft radius, or, when that can't
518    /// be resolved, the next one's forward radius. A centering ring's or inner tube's outer
519    /// radius: its parent's inner radius, which is how a coupler or an engine block fills the tube
520    /// it sits in; inside a hollow nose cone or transition, the parent's outer radius at the
521    /// narrower end of the part less the parent's wall, and never below zero, as OpenRocket 24.12
522    /// reads it ([ADR-096][adr-096]). An inner tube whose resolved radius is less than its wall
523    /// is laid out solid (its wall is cut to its radius), as OpenRocket weighs it; a stated radius
524    /// with too thick a wall is still refused. A tube fin set's outer radius: the radius at which
525    /// its tubes close the ring around the body tube they sit on,
526    /// [`TubeFinSet::closing_radius_m`](crate::TubeFinSet::closing_radius_m), its wall cut to that
527    /// radius when thicker, as OpenRocket 24.12 reads it ([ADR-098][adr-098]).
528    ///
529    /// [adr-098]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-098-a-tube-fin-sets-automatic-radius-read-as-openrocket-reads-it-2026-09-28
530    /// [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
531    OuterRadius,
532    /// A transition's forward radius: the previous body component's aft radius.
533    ForeRadius,
534    /// A transition's aft radius: the next body component's forward radius.
535    AftRadius,
536    /// A nose cone's shoulder outer radius: the inner radius of the body tube behind it.
537    ShoulderRadius,
538    /// A transition's forward shoulder outer radius: the inner radius of the body tube ahead of it.
539    ForeShoulderRadius,
540    /// A transition's aft shoulder outer radius: the inner radius of the body tube behind it.
541    AftShoulderRadius,
542    /// A centering ring's inner radius: the outer radius of the widest on-axis inner tube among
543    /// its siblings that overlaps it along the axis, or zero when none does.
544    InnerRadius,
545    /// A mass component's or recovery part's packed radius: its parent's inner radius less the
546    /// part's radial offset.
547    PackedRadius,
548}
549
550impl AutoDimension {
551    /// The dimension's name, as serialized.
552    pub fn name(self) -> &'static str {
553        match self {
554            Self::BaseRadius => "base_radius",
555            Self::OuterRadius => "outer_radius",
556            Self::ForeRadius => "fore_radius",
557            Self::AftRadius => "aft_radius",
558            Self::ShoulderRadius => "shoulder_radius",
559            Self::ForeShoulderRadius => "fore_shoulder_radius",
560            Self::AftShoulderRadius => "aft_shoulder_radius",
561            Self::InnerRadius => "inner_radius",
562            Self::PackedRadius => "packed_radius",
563        }
564    }
565
566    fn applies_to(self, part: &Part) -> bool {
567        use AutoDimension as A;
568        matches!(
569            (self, part),
570            (A::BaseRadius | A::ShoulderRadius, Part::NoseCone(_))
571                | (
572                    A::OuterRadius,
573                    Part::BodyTube(_)
574                        | Part::CenteringRing(_)
575                        | Part::InnerTube(_)
576                        | Part::TubeFinSet(_)
577                )
578                | (
579                    A::ForeRadius | A::AftRadius | A::ForeShoulderRadius | A::AftShoulderRadius,
580                    Part::Transition(_)
581                )
582                | (A::InnerRadius, Part::CenteringRing(_))
583                | (
584                    A::PackedRadius,
585                    Part::MassComponent(_)
586                        | Part::Parachute(_)
587                        | Part::Streamer(_)
588                        | Part::ShockCord(_)
589                )
590        )
591    }
592}
593
594/// Values that replace the mass properties computed from geometry. Each applies in turn:
595///
596/// 1. **Mass** `m′`: the body is rescaled, `I′ = I m′/m`, keeping its center and shape. A body with
597///    no mass becomes a point mass at its center, but for a packed part in a layout (a mass
598///    component, parachute, streamer or shock cord), which takes `m′` as a solid cylinder of its
599///    packing, as OpenRocket 24.12 does ([ADR-063][adr-063]).
600/// 2. **Center of mass**: the center moves along the axis to `cg_aft_m` aft of the component's
601///    forward end (a stage's, for a stage), with or without its children, and off the axis to
602///    `cg_xy_m` when given (otherwise it keeps its offset); the tensor about the center is
603///    unchanged.
604/// 3. **Inertia**: the tensor about the (new) center is replaced.
605///
606/// [adr-063]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-063-packed-parts-read-and-weighed-as-openrocket-packs-them-2026-09-21
607#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
608#[serde(deny_unknown_fields)]
609pub struct Overrides {
610    /// Mass, kg.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub mass_kg: Option<f64>,
613    /// Center of mass, m aft of the forward end of the component (or the stage).
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub cg_aft_m: Option<f64>,
616    /// Center of mass off the axis, `[x, y]` in body axes, m. For a part inside a cluster's tube or
617    /// a pod, it is measured in that one copy as written (a pod on the body's axis), and the copies
618    /// carry it to each place.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub cg_xy_m: Option<[f64; 2]>,
621    /// Inertia tensor about the center of mass. For a part inside a cluster's tube or a pod, it is
622    /// in that one copy's axes as written, and turns with each pod.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub inertia: Option<InertiaOverride>,
625}
626
627/// A drag coefficient stated in place of the one a component's geometry gives: OpenRocket's
628/// drag-coefficient override (`<overridecd>`, `<overridesubcomponentscd>`), as OpenRocket 24.12
629/// flies it, measured on probe designs (`validation/oracles/openrocket/drag_override.py`,
630/// [ADR-167][adr-167]):
631///
632/// - The coefficient is on the rocket's reference area and is the same at every Mach number.
633/// - It replaces all the component's own zero-lift drag: skin friction, pressure drag and the
634///   base drag of its aft face, whether that is the rocket's base or a step down to the part
635///   behind it. A step up at its fore end is its own face, and goes too.
636/// - It counts once per instance: per fin of a fin set, per lug or button of a set.
637/// - On a tube fin set, a pod set or a part in a pod, or covering a pod set, it was not measured,
638///   and `hpr-aero` refuses it by name.
639/// - With `include_children`, the parts attached to the component have no drag of their own.
640/// - On a part inside the body (an inner tube, a ring, a mass) it does nothing, as such a part
641///   has no drag.
642/// - On a stage, it is added to the rocket's drag; with `include_children`, it replaces the
643///   drag of everything in the stage.
644///
645/// [adr-167]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0167-a-part-s-drag-override-as-openrocket-flies-it.md
646#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
647#[serde(deny_unknown_fields)]
648pub struct DragOverride {
649    /// The zero-lift drag coefficient, on the rocket's reference area, per instance. Zero is a
650    /// value: no drag at all.
651    pub coefficient: f64,
652    /// Whether the parts attached to the component (or everything in the stage) lose their own
653    /// drag too.
654    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
655    pub include_children: bool,
656}
657
658/// An inertia tensor about the center of mass in body axes, kg·m². The off-diagonal entries are
659/// the tensor's, `I_xy = −∫ x y dm` ([`crate::mass`]); they default to zero.
660#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
661#[serde(deny_unknown_fields)]
662pub struct InertiaOverride {
663    /// `I_xx`, kg·m².
664    pub xx_kg_m2: f64,
665    /// `I_yy`, kg·m².
666    pub yy_kg_m2: f64,
667    /// `I_zz`, about the rocket's axis, kg·m².
668    pub zz_kg_m2: f64,
669    /// `I_xy`, kg·m².
670    #[serde(default)]
671    pub xy_kg_m2: f64,
672    /// `I_xz`, kg·m².
673    #[serde(default)]
674    pub xz_kg_m2: f64,
675    /// `I_yz`, kg·m².
676    #[serde(default)]
677    pub yz_kg_m2: f64,
678}
679
680impl InertiaOverride {
681    /// A tensor symmetric about the body axis: `diag(I_t, I_t, I_a)`.
682    pub fn axisymmetric(axial_kg_m2: f64, transverse_kg_m2: f64) -> Self {
683        Self {
684            xx_kg_m2: transverse_kg_m2,
685            yy_kg_m2: transverse_kg_m2,
686            zz_kg_m2: axial_kg_m2,
687            xy_kg_m2: 0.0,
688            xz_kg_m2: 0.0,
689            yz_kg_m2: 0.0,
690        }
691    }
692
693    /// The tensor.
694    pub fn tensor(&self) -> DMat3 {
695        DMat3::from_cols(
696            DVec3::new(self.xx_kg_m2, self.xy_kg_m2, self.xz_kg_m2),
697            DVec3::new(self.xy_kg_m2, self.yy_kg_m2, self.yz_kg_m2),
698            DVec3::new(self.xz_kg_m2, self.yz_kg_m2, self.zz_kg_m2),
699        )
700    }
701}
702
703impl Overrides {
704    /// Whether the center of mass is overridden, along the axis or off it.
705    pub fn sets_center(&self) -> bool {
706        self.cg_aft_m.is_some() || self.cg_xy_m.is_some()
707    }
708
709    /// Whether the center of mass is moved along the axis (`cg_aft_m`).
710    pub fn sets_axial_center(&self) -> bool {
711        self.cg_aft_m.is_some()
712    }
713
714    /// Whether nothing is overridden.
715    pub fn is_empty(&self) -> bool {
716        self.mass_kg.is_none()
717            && self.cg_aft_m.is_none()
718            && self.cg_xy_m.is_none()
719            && self.inertia.is_none()
720    }
721
722    /// Applies the overrides to `mass`, measuring a center-of-mass override from station
723    /// `fore_station_m`.
724    ///
725    /// # Errors
726    ///
727    /// [`DesignError::Domain`] for a negative or non-finite mass or a non-finite center, and
728    /// [`DesignError::UnphysicalInertia`] when the result is not a real body
729    /// ([`MassProperties::validate`]).
730    pub fn apply(
731        &self,
732        mass: MassProperties,
733        fore_station_m: f64,
734    ) -> Result<MassProperties, DesignError> {
735        if self.is_empty() {
736            return Ok(mass);
737        }
738        let mut out = mass;
739        if let Some(m) = self.mass_kg {
740            check_dimension("mass override (kg)", m, true)?;
741            out = if out.mass_kg > 0.0 {
742                out.scaled(m / out.mass_kg)
743            } else {
744                MassProperties::point(m, out.cg_m)
745            };
746        }
747        if let Some(aft) = self.cg_aft_m {
748            if !aft.is_finite() {
749                return Err(DesignError::Domain {
750                    what: "center-of-mass override (m)",
751                    value: aft,
752                });
753            }
754            out.cg_m.z = -(fore_station_m + aft);
755        }
756        if let Some([x, y]) = self.cg_xy_m {
757            for value in [x, y] {
758                if !value.is_finite() {
759                    return Err(DesignError::Domain {
760                        what: "off-axis center-of-mass override (m)",
761                        value,
762                    });
763                }
764            }
765            out.cg_m.x = x;
766            out.cg_m.y = y;
767        }
768        if let Some(inertia) = self.inertia {
769            out.inertia_kg_m2 = inertia.tensor();
770        }
771        out.validate()?;
772        Ok(out)
773    }
774}
775
776/// How the reference diameter (for aerodynamic coefficients) is chosen.
777#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
778#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
779#[non_exhaustive]
780pub enum ReferenceDiameter {
781    /// The widest body component (nose cone, body tube or transition) in any stage. Internal
782    /// parts, shoulders, fins, tube fins, lugs and rail buttons don't count.
783    Maximum {},
784    /// The base of the first nose cone.
785    NoseBase {},
786    /// A given diameter.
787    Custom {
788        /// Diameter, m.
789        diameter_m: f64,
790    },
791}
792
793impl Default for ReferenceDiameter {
794    fn default() -> Self {
795        Self::Maximum {}
796    }
797}
798
799/// A component resolved into place.
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
801pub struct PlacedComponent {
802    /// The component's id.
803    pub id: String,
804    /// Index of its stage in [`Layout::stages`].
805    pub stage: usize,
806    /// Index of its parent in [`Layout::components`]; `None` for a body component of the airframe
807    /// (a pod's body components have their pod set as parent).
808    pub parent: Option<usize>,
809    /// The part with every automatic dimension filled in.
810    pub part: Part,
811    /// Station of its forward end (the start of its axial extent), m aft of the nose tip.
812    pub fore_station_m: f64,
813    /// Axial extent ([`Component::length_m`]: a pod set's is its pod's), m.
814    pub length_m: f64,
815    /// The outer surface's finish ([`Component::finish`], with the default filled in).
816    #[serde(default)]
817    pub finish: Finish,
818    /// For an external attachment, the radius of the body tube it sits on, m.
819    pub body_radius_m: Option<f64>,
820    /// Its motor mount, if it is one.
821    pub motor_mount: Option<MotorMount>,
822    /// Its own mass properties in body axes, after overrides that cover it alone.
823    pub own: MassProperties,
824    /// It with everything attached to it, after every override that applies within.
825    pub with_children: MassProperties,
826    /// Whether its own overrides move its center of mass along the axis (`cg_aft_m`). Written
827    /// before the move to US spelling as `centre_overridden`, which is still read and never written.
828    #[serde(default, alias = "centre_overridden")]
829    pub center_overridden: bool,
830    /// Where the copies of it sit, each a [`Placement`] of it from where it is written: one
831    /// [`Placement::HERE`] for a part in no cluster or pod, one per tube for a part inside a
832    /// clustered inner tube ([`InnerTube::cluster_m`](crate::InnerTube::cluster_m)), and one per
833    /// pod, turned with it, for a part in a pod ([`PodSet::pods`]); nested, every combination.
834    /// [`Self::own`] and [`Self::with_children`] count every copy. A clustered tube's own tubes are
835    /// its part's. An empty list is no copy: the part weighs nothing and a motor in it is not
836    /// placed.
837    #[serde(default = "one_copy")]
838    pub copies: Vec<Placement>,
839    /// The drag coefficient the component states ([`Component::drag_override`]).
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub drag_override: Option<DragOverride>,
842}
843
844/// One copy, where it is written.
845fn one_copy() -> Vec<Placement> {
846    vec![Placement::HERE]
847}
848
849impl PlacedComponent {
850    /// Station of the aft end of its axial extent, m.
851    pub fn aft_station_m(&self) -> f64 {
852        self.fore_station_m + self.length_m
853    }
854
855    /// Where the copies of what it holds sit, from where that is written: for a clustered inner
856    /// tube, every tube of every copy of it; for a pod set, every pod of every copy of it; for any
857    /// other part, its own copies. A motor in a clustered mount, or in a pod, is one motor per
858    /// place.
859    ///
860    /// # Errors
861    ///
862    /// [`DesignError::Domain`] for a cluster offset that is not finite, or a pod set's errors from
863    /// [`PodSet::pods`].
864    pub fn contents_copies(&self) -> Result<Vec<Placement>, DesignError> {
865        let repeats = repeats(&self.part)?;
866        Ok(self
867            .copies
868            .iter()
869            .flat_map(|copy| repeats.iter().map(move |inner| copy.after(inner)))
870            .collect())
871    }
872}
873
874/// Where what `part` holds is repeated, from where one copy of `part` is written: a cluster's
875/// tubes, a pod set's pods, and one place for any other part.
876fn repeats(part: &Part) -> Result<Vec<Placement>, DesignError> {
877    match part {
878        Part::InnerTube(tube) => Ok(tube.tubes_m()?.into_iter().map(Placement::moved).collect()),
879        Part::PodSet(pods) => pods.pods(),
880        _ => Ok(one_copy()),
881    }
882}
883
884/// A stage resolved into place.
885#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
886pub struct PlacedStage {
887    /// The stage's id.
888    pub id: String,
889    /// Station of its forward end, m.
890    pub fore_station_m: f64,
891    /// Station of its aft end, m.
892    pub aft_station_m: f64,
893    /// Its mass properties in body axes, without motors, after its overrides.
894    pub mass: MassProperties,
895    /// Whether the stage's own overrides move its center of mass along the axis (`cg_aft_m`).
896    /// Written before the move to US spelling as `centre_overridden`, which is still read and never written.
897    #[serde(default, alias = "centre_overridden")]
898    pub center_overridden: bool,
899    /// The drag coefficient the stage states ([`Stage::drag_override`]).
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub drag_override: Option<DragOverride>,
902    /// For a parallel stage ([`Stage::parallel`]), the index of the stage whose body tube it hangs
903    /// on; `None` for a stage on the axis. Its stations are its pods'.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub hung_on: Option<usize>,
906}
907
908/// Each stage's masses before its overrides, as [`Rocket::layout`] combines them, and where it
909/// starts and ends: what a layout's stages are made from.
910#[derive(Debug, Clone, PartialEq)]
911pub(crate) struct StageMasses {
912    /// Each stage's top-level components' masses, with their children, fore to aft.
913    masses: Vec<Vec<MassProperties>>,
914    /// Each stage's fore and aft stations, m.
915    ends: Vec<Option<(f64, f64)>>,
916    /// The rocket's length, m.
917    length_m: f64,
918}
919
920/// A design resolved into placed parts, with its structural mass properties (no motors).
921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
922pub struct Layout {
923    /// Every component, depth first: each body component followed by its attached parts.
924    pub components: Vec<PlacedComponent>,
925    /// The stages, forward to aft.
926    pub stages: Vec<PlacedStage>,
927    /// Length from the nose tip to the aft end of the airframe's last body component, m. Pods that
928    /// run past it don't count.
929    pub length_m: f64,
930    /// Reference diameter, m.
931    pub reference_diameter_m: f64,
932    /// Every stage together, without motors.
933    pub structure: MassProperties,
934}
935
936impl Layout {
937    /// The component with id `id`, and its index.
938    pub fn find(&self, id: &str) -> Option<(usize, &PlacedComponent)> {
939        self.components.iter().enumerate().find(|(_, c)| c.id == id)
940    }
941
942    /// The airframe's body components, forward to aft: not a pod's.
943    pub fn body(&self) -> impl Iterator<Item = &PlacedComponent> {
944        self.components.iter().filter(|c| c.parent.is_none())
945    }
946
947    /// The index of the pod set that component `index` is in, if any: the nearest pod set among
948    /// its parents, never the component itself. The walk takes at most one step per component,
949    /// so a layout whose parents loop, which [`Layout`]'s own builder never makes, ends it.
950    pub fn pod_set_of(&self, index: usize) -> Option<usize> {
951        let mut parent = self.components.get(index)?.parent;
952        for _ in 0..self.components.len() {
953            let at = parent?;
954            let component = self.components.get(at)?;
955            if matches!(component.part, Part::PodSet(_)) {
956                return Some(at);
957            }
958            parent = component.parent;
959        }
960        None
961    }
962
963    /// The pod sets that hold a motor mount, as [`Self::pod_set_of`] finds a mount's, each once, in
964    /// the order their first mount comes in the layout. A motor's place in this list says which
965    /// pod set's bases take its area under power.
966    pub fn motor_pod_sets(&self) -> Vec<usize> {
967        let mut sets = Vec::new();
968        for (index, component) in self.components.iter().enumerate() {
969            if component.motor_mount.is_none() {
970                continue;
971            }
972            if let Some(set) = self.pod_set_of(index)
973                && !sets.contains(&set)
974            {
975                sets.push(set);
976            }
977        }
978        sets
979    }
980
981    /// Reference area `π d²/4`, m².
982    pub fn reference_area_m2(&self) -> f64 {
983        0.25 * std::f64::consts::PI * self.reference_diameter_m * self.reference_diameter_m
984    }
985}
986
987/// An automatic body radius that has nothing to take: where it is, and which radius
988/// ([`Rocket::unresolvable_body_radii`]).
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990pub struct UnresolvableRadius {
991    /// Index of the component's stage in [`Rocket::stages`].
992    pub stage: usize,
993    /// Index of the component in that stage's [`Stage::components`].
994    pub component: usize,
995    /// The component's id.
996    pub id: String,
997    /// Which of its radii: [`AutoDimension::BaseRadius`], [`AutoDimension::OuterRadius`],
998    /// [`AutoDimension::ForeRadius`] or [`AutoDimension::AftRadius`].
999    pub dimension: AutoDimension,
1000}
1001
1002impl Rocket {
1003    /// Resolves the tree: checks its structure, fills in automatic dimensions, places every part,
1004    /// and computes the mass properties with overrides.
1005    ///
1006    /// # Errors
1007    ///
1008    /// - [`DesignError::DuplicateId`] for an empty or repeated stage or component id.
1009    /// - [`DesignError::Tree`] for a rocket with no stages, an empty stage, a part in the wrong
1010    ///   place (an attached part in a stage's list, a body component attached anywhere but in a pod
1011    ///   set, anything else in a pod set, fins or pods on anything but a body tube, a pod set in a
1012    ///   pod, children under anything but a body component, inner tube or pod set), a pod set with
1013    ///   an override that doesn't cover its pods, a missing or
1014    ///   unexpected position, an automatic dimension that doesn't apply or can't be resolved, or a
1015    ///   motor mount on anything but a body tube or inner tube.
1016    /// - [`DesignError::Domain`] for a pod set of no pods or more than [`PodSet::MAX_COUNT`], a
1017    ///   radial offset or angle out of range, or a body component of no length in a stage.
1018    /// - Any part's geometry, material or numerical error, a custom finish's negative or
1019    ///   non-finite roughness, and override errors.
1020    pub fn layout(&self) -> Result<Layout, DesignError> {
1021        let (components, masses) = self.placed_components()?;
1022        self.staged_layout(components, &masses)
1023    }
1024
1025    /// The body components placed, with their children, and each stage's masses before its
1026    /// overrides: all of [`Rocket::layout`] but the stages.
1027    pub(crate) fn placed_components(
1028        &self,
1029    ) -> Result<(Vec<PlacedComponent>, StageMasses), DesignError> {
1030        if self.stages.is_empty() {
1031            return Err(DesignError::Tree {
1032                id: self.name.clone(),
1033                message: "a rocket needs at least one stage".to_owned(),
1034            });
1035        }
1036        let mut ids = BTreeSet::new();
1037        for stage in &self.stages {
1038            if stage.components.is_empty() {
1039                return Err(tree(&stage.id, "a stage needs at least one body component"));
1040            }
1041            if stage.parallel.is_some() {
1042                // Checked below as the pod set it is laid out as, under the stage's own id.
1043                continue;
1044            }
1045            unique(&mut ids, &stage.id)?;
1046            for component in &stage.components {
1047                check_node(&mut ids, component, None, 0, false)?;
1048            }
1049        }
1050        let mut hung = Hung::default();
1051        for ((k, stage), carrier) in self.stages.iter().enumerate().zip(self.carriers()?) {
1052            let (Some(parallel), Some(carrier)) = (&stage.parallel, carrier) else {
1053                continue;
1054            };
1055            let on = &self.stages[carrier.stage].components[carrier.component];
1056            if matches!(parallel.position, Position::After { .. }) {
1057                return Err(tree(
1058                    &stage.id,
1059                    "a parallel stage is placed along the tube it hangs on, not after a sibling",
1060                ));
1061            }
1062            // A mass override that covers what the tube holds, or the whole stage it is in, doesn't
1063            // say whether it covers the parallel stage too: OpenRocket's tree holds the stage
1064            // under the tube, and no probe has measured its reading (ADR-171).
1065            if !self.stages[carrier.stage].overrides.is_empty()
1066                || (on.overrides_include_children && !on.overrides.is_empty())
1067            {
1068                return Err(tree(
1069                    &stage.id,
1070                    format!(
1071                        "a parallel stage hangs on `{}`, under a mass override that covers what \
1072                         the tube or its stage holds, which doesn't say whether it covers the \
1073                         parallel stage",
1074                        on.id
1075                    ),
1076                ));
1077            }
1078            let pods = parallel_pods(stage, parallel);
1079            check_node(&mut ids, &pods, Some(&on.part), 1, false)?;
1080            hung.stages
1081                .entry(on.id.clone())
1082                .or_default()
1083                .push((k, pods));
1084        }
1085
1086        let nodes: Vec<(usize, &Component)> = self
1087            .stages
1088            .iter()
1089            .enumerate()
1090            .filter(|(_, stage)| stage.parallel.is_none())
1091            .flat_map(|(k, stage)| stage.components.iter().map(move |c| (k, c)))
1092            .collect();
1093        let mut parts: Vec<Part> = nodes.iter().map(|(_, c)| c.part.clone()).collect();
1094        let autos: Vec<&[AutoDimension]> = nodes.iter().map(|(_, c)| c.auto.as_slice()).collect();
1095        let ids: Vec<&str> = nodes.iter().map(|(_, c)| c.id.as_str()).collect();
1096        resolve_body_radii(&mut parts, &autos, &ids)?;
1097        resolve_shoulders(&mut parts, &autos, &ids)?;
1098
1099        let mut components = Vec::new();
1100        let mut stage_masses: Vec<Vec<MassProperties>> = vec![Vec::new(); self.stages.len()];
1101        let mut stage_ends: Vec<Option<(f64, f64)>> = vec![None; self.stages.len()];
1102        let mut station = 0.0;
1103        for ((stage, node), part) in nodes.iter().zip(parts) {
1104            let length = part.length_m();
1105            check_dimension("body component length", length, false)
1106                .map_err(|e| within(&node.id, e))?;
1107            let placed = place(&part, None, station, &node.id)?;
1108            let index = components.len();
1109            components.push(PlacedComponent {
1110                id: node.id.clone(),
1111                stage: *stage,
1112                parent: None,
1113                part,
1114                fore_station_m: station,
1115                length_m: length,
1116                finish: node.finish.unwrap_or_default(),
1117                body_radius_m: None,
1118                motor_mount: node.motor_mount,
1119                own: placed,
1120                with_children: placed,
1121                center_overridden: node.overrides.sets_axial_center(),
1122                copies: one_copy(),
1123                drag_override: node.drag_override,
1124            });
1125            let with_children = finish(&mut components, index, node, &mut hung)?;
1126            stage_masses[*stage].push(with_children);
1127            let ends = stage_ends[*stage].get_or_insert((station, station));
1128            ends.1 = station + length;
1129            station += length;
1130        }
1131        for (k, mass, ends) in hung.placed {
1132            stage_masses[k].push(mass);
1133            stage_ends[k] = Some(ends);
1134        }
1135        Ok((
1136            components,
1137            StageMasses {
1138                masses: stage_masses,
1139                ends: stage_ends,
1140                length_m: station,
1141            },
1142        ))
1143    }
1144
1145    /// The layout of `components`, placed by [`Rocket::placed_components`] from this design or
1146    /// from one with the same stage components, with this design's stage overrides applied to
1147    /// `masses`: the rest of [`Rocket::layout`].
1148    pub(crate) fn staged_layout(
1149        &self,
1150        components: Vec<PlacedComponent>,
1151        masses: &StageMasses,
1152    ) -> Result<Layout, DesignError> {
1153        let mut stages = Vec::with_capacity(self.stages.len());
1154        let carriers = self.carriers()?;
1155        for ((stage, masses), ends) in self.stages.iter().zip(&masses.masses).zip(&masses.ends) {
1156            let (fore, aft) = ends.unwrap_or((0.0, 0.0));
1157            let mass = stage
1158                .overrides
1159                .apply(MassProperties::combine(masses), fore)
1160                .map_err(|e| within(&stage.id, e))?;
1161            let hung_on = carriers[stages.len()].map(|carrier| carrier.stage);
1162            stages.push(PlacedStage {
1163                id: stage.id.clone(),
1164                fore_station_m: fore,
1165                aft_station_m: aft,
1166                mass,
1167                center_overridden: stage.overrides.sets_axial_center(),
1168                drag_override: stage.drag_override,
1169                hung_on,
1170            });
1171        }
1172        let structure = MassProperties::combine(stages.iter().map(|s| &s.mass));
1173        let reference_diameter_m = self.reference_diameter_m(&components)?;
1174        Ok(Layout {
1175            components,
1176            stages,
1177            length_m: masses.length_m,
1178            reference_diameter_m,
1179            structure,
1180        })
1181    }
1182
1183    /// The automatic body radii that [`layout`](Self::layout) cannot resolve, forward to aft.
1184    ///
1185    /// These are the radii on a chain of automatic radii with no fixed radius anywhere along it:
1186    /// a nose cone whose base looks back at a tube that looks forward at it, or a stage of tubes
1187    /// that all say "automatic". Neighbours are followed by the rule each [`AutoDimension`]
1188    /// documents. `layout` refuses a design for which this list is not empty; what such a radius
1189    /// should be is not in the design, so an importer that knows its source program's convention
1190    /// fills them in first ([`fill_unresolvable_body_radii`](Self::fill_unresolvable_body_radii)).
1191    ///
1192    /// A parallel stage's body components are not listed: they take their radii from one another,
1193    /// as a pod's do, and `layout` refuses one it can't resolve.
1194    ///
1195    /// Only the body components' own radii are listed. An automatic shoulder with no body tube
1196    /// beside it, or an automatic dimension a part does not have, is refused by `layout` with its
1197    /// own error instead.
1198    pub fn unresolvable_body_radii(&self) -> Vec<UnresolvableRadius> {
1199        let nodes: Vec<(usize, usize, &Component)> = self
1200            .stages
1201            .iter()
1202            .enumerate()
1203            .filter(|(_, stage)| stage.parallel.is_none())
1204            .flat_map(|(k, stage)| {
1205                stage
1206                    .components
1207                    .iter()
1208                    .enumerate()
1209                    .map(move |(i, c)| (k, i, c))
1210            })
1211            .collect();
1212        let parts: Vec<&Part> = nodes.iter().map(|(_, _, c)| &c.part).collect();
1213        let autos: Vec<&[AutoDimension]> =
1214            nodes.iter().map(|(_, _, c)| c.auto.as_slice()).collect();
1215        let (fore, aft) = sweep_body_radii(&parts, &autos);
1216        let mut unresolvable = Vec::new();
1217        for (n, &(stage, component, node)) in nodes.iter().enumerate() {
1218            let mut add = |dimension| {
1219                unresolvable.push(UnresolvableRadius {
1220                    stage,
1221                    component,
1222                    id: node.id.clone(),
1223                    dimension,
1224                });
1225            };
1226            match node.part {
1227                Part::NoseCone(_) if aft[n].is_none() => add(AutoDimension::BaseRadius),
1228                Part::BodyTube(_) if aft[n].is_none() => add(AutoDimension::OuterRadius),
1229                Part::Transition(_) => {
1230                    if fore[n].is_none() {
1231                        add(AutoDimension::ForeRadius);
1232                    }
1233                    if aft[n].is_none() {
1234                        add(AutoDimension::AftRadius);
1235                    }
1236                }
1237                _ => {}
1238            }
1239        }
1240        unresolvable
1241    }
1242
1243    /// Gives every radius [`unresolvable_body_radii`](Self::unresolvable_body_radii) lists the
1244    /// fixed radius `radius_m`, drops its automatic mark, and returns what it filled.
1245    ///
1246    /// Every radius on such a chain is listed, so the whole chain takes the one radius. Radii the
1247    /// neighbour rule could already reach are left as they are and resolve as before. The design
1248    /// no longer records that the filled radii were automatic, which is the point: it now says
1249    /// what they are.
1250    pub fn fill_unresolvable_body_radii(&mut self, radius_m: f64) -> Vec<UnresolvableRadius> {
1251        let unresolvable = self.unresolvable_body_radii();
1252        for radius in &unresolvable {
1253            let component = &mut self.stages[radius.stage].components[radius.component];
1254            match (&mut component.part, radius.dimension) {
1255                (Part::NoseCone(p), AutoDimension::BaseRadius) => p.base_radius_m = radius_m,
1256                (Part::BodyTube(p), AutoDimension::OuterRadius) => p.outer_radius_m = radius_m,
1257                (Part::Transition(p), AutoDimension::ForeRadius) => p.fore_radius_m = radius_m,
1258                (Part::Transition(p), AutoDimension::AftRadius) => p.aft_radius_m = radius_m,
1259                // `unresolvable_body_radii` lists only the four pairings above.
1260                _ => continue,
1261            }
1262            component.auto.retain(|auto| *auto != radius.dimension);
1263        }
1264        unresolvable
1265    }
1266
1267    fn reference_diameter_m(&self, components: &[PlacedComponent]) -> Result<f64, DesignError> {
1268        let diameter = match self.reference_diameter {
1269            ReferenceDiameter::Maximum {} => {
1270                let mut widest = 0.0f64;
1271                for c in components.iter().filter(|c| c.parent.is_none()) {
1272                    if let Some(r) = c.part.max_radius_m()? {
1273                        widest = widest.max(r);
1274                    }
1275                }
1276                2.0 * widest
1277            }
1278            // The airframe's nose cone, not a pod's.
1279            ReferenceDiameter::NoseBase {} => components
1280                .iter()
1281                .filter(|c| c.parent.is_none())
1282                .find_map(|c| match &c.part {
1283                    Part::NoseCone(nose) => Some(2.0 * nose.base_radius_m),
1284                    _ => None,
1285                })
1286                .ok_or_else(|| {
1287                    tree(
1288                        &self.name,
1289                        "the reference diameter is the nose base, but there is no nose cone",
1290                    )
1291                })?,
1292            ReferenceDiameter::Custom { diameter_m } => diameter_m,
1293        };
1294        check_dimension("reference diameter (m)", diameter, false)?;
1295        Ok(diameter)
1296    }
1297}
1298
1299/// `error` in the stage or component `id`.
1300fn within(id: &str, error: DesignError) -> DesignError {
1301    DesignError::InComponent {
1302        id: id.to_owned(),
1303        source: Box::new(error),
1304    }
1305}
1306
1307/// How many levels a body component and the parts nested in it may span, the body component being
1308/// the first: inner tubes in inner tubes far beyond any real rocket, and shallow enough that
1309/// resolving never exhausts a small (wasm) stack.
1310pub const MAX_DEPTH: usize = 32;
1311
1312fn tree(id: &str, message: impl Into<String>) -> DesignError {
1313    DesignError::Tree {
1314        id: id.to_owned(),
1315        message: message.into(),
1316    }
1317}
1318
1319fn unique(ids: &mut BTreeSet<String>, id: &str) -> Result<(), DesignError> {
1320    if id.is_empty() || !ids.insert(id.to_owned()) {
1321        return Err(DesignError::DuplicateId(id.to_owned()));
1322    }
1323    Ok(())
1324}
1325
1326/// Checks a node's id, role, position, automatic dimensions and motor mount against where it sits,
1327/// and then its children. `parent` is `None` for a body component.
1328fn check_node(
1329    ids: &mut BTreeSet<String>,
1330    node: &Component,
1331    parent: Option<&Part>,
1332    depth: usize,
1333    in_pod: bool,
1334) -> Result<(), DesignError> {
1335    unique(ids, &node.id)?;
1336    if let Some(finish) = node.finish {
1337        finish.roughness_m().map_err(|e| within(&node.id, e))?;
1338    }
1339    if depth >= MAX_DEPTH {
1340        return Err(tree(
1341            &node.id,
1342            format!("components nest more than {MAX_DEPTH} deep"),
1343        ));
1344    }
1345    let kind = node.part.kind_name();
1346    match (parent, node.part.role()) {
1347        (None | Some(Part::PodSet(_)), Role::Body) => {
1348            if node.position.is_some() {
1349                return Err(tree(
1350                    &node.id,
1351                    "a body component stacks and takes no position",
1352                ));
1353            }
1354        }
1355        (Some(Part::PodSet(_)), _) => {
1356            return Err(tree(
1357                &node.id,
1358                format!("a pod holds body components; attach a {kind} to one of them"),
1359            ));
1360        }
1361        (None, _) => {
1362            return Err(tree(
1363                &node.id,
1364                format!("a {kind} can't be a body component; attach it to one"),
1365            ));
1366        }
1367        (Some(_), Role::Body) => {
1368            return Err(tree(
1369                &node.id,
1370                format!("a {kind} is a body component; list it in a stage"),
1371            ));
1372        }
1373        // A fin set may also sit on a nose cone or a transition, its root along the surface
1374        // (ADR-166; checked where it is placed).
1375        (Some(parent), Role::External)
1376            if !(matches!(parent, Part::BodyTube(_))
1377                || matches!(node.part, Part::FinSet(_))
1378                    && matches!(parent, Part::NoseCone(_) | Part::Transition(_))) =>
1379        {
1380            return Err(tree(
1381                &node.id,
1382                format!(
1383                    "a {kind} attaches to a body tube{}, not a {}",
1384                    if matches!(node.part, Part::FinSet(_)) {
1385                        ", a nose cone or a transition"
1386                    } else {
1387                        ""
1388                    },
1389                    parent.kind_name()
1390                ),
1391            ));
1392        }
1393        (Some(parent), Role::Internal)
1394            if !(parent.is_body() || matches!(parent, Part::InnerTube(_))) =>
1395        {
1396            return Err(tree(
1397                &node.id,
1398                format!("a {kind} can't go inside a {}", parent.kind_name()),
1399            ));
1400        }
1401        (Some(_), _) => {
1402            if node.position.is_none() {
1403                return Err(tree(&node.id, "an attached part needs a position"));
1404            }
1405        }
1406    }
1407    for auto in &node.auto {
1408        if !auto.applies_to(&node.part) {
1409            return Err(tree(
1410                &node.id,
1411                format!("a {kind} has no automatic {} dimension", auto.name()),
1412            ));
1413        }
1414    }
1415    if matches!(node.part, Part::PodSet(_)) {
1416        if in_pod {
1417            return Err(tree(&node.id, "a pod set can't hang from a pod"));
1418        }
1419        if !node.overrides.is_empty() && !node.overrides_include_children {
1420            return Err(tree(
1421                &node.id,
1422                "a pod set weighs nothing of its own, so an override on it must cover its pods \
1423                 (`overrides_include_children`)",
1424            ));
1425        }
1426    }
1427    if node.motor_mount.is_some() && !matches!(node.part, Part::BodyTube(_) | Part::InnerTube(_)) {
1428        return Err(tree(&node.id, format!("a {kind} can't be a motor mount")));
1429    }
1430    if !node.children.is_empty()
1431        && !(node.part.is_body() || matches!(node.part, Part::InnerTube(_) | Part::PodSet(_)))
1432    {
1433        return Err(tree(
1434            &node.id,
1435            format!("a {kind} can't have attached parts"),
1436        ));
1437    }
1438    for child in &node.children {
1439        let in_pod = in_pod || matches!(node.part, Part::PodSet(_));
1440        check_node(ids, child, Some(&node.part), depth + 1, in_pod)?;
1441    }
1442    Ok(())
1443}
1444
1445/// Fills in the body components' automatic outer radii from their neighbours, through every stage,
1446/// by [`sweep_body_radii`]'s rule; a radius the sweep leaves unknown is an error.
1447fn resolve_body_radii(
1448    parts: &mut [Part],
1449    autos: &[&[AutoDimension]],
1450    ids: &[&str],
1451) -> Result<(), DesignError> {
1452    let (fore, aft) = sweep_body_radii(parts, autos);
1453    for (i, part) in parts.iter_mut().enumerate() {
1454        let missing = || {
1455            tree(
1456                ids[i],
1457                "an automatic radius has no fixed radius among its neighbours to take",
1458            )
1459        };
1460        match part {
1461            Part::NoseCone(p) => p.base_radius_m = aft[i].ok_or_else(missing)?,
1462            Part::BodyTube(p) => p.outer_radius_m = aft[i].ok_or_else(missing)?,
1463            Part::Transition(p) => {
1464                p.fore_radius_m = fore[i].ok_or_else(missing)?;
1465                p.aft_radius_m = aft[i].ok_or_else(missing)?;
1466            }
1467            _ => {}
1468        }
1469    }
1470    Ok(())
1471}
1472
1473/// Each body component's forward and aft radius, following automatic radii to their sources;
1474/// `None` where a radius is automatic and nothing reaches it. `parts` are the body components,
1475/// forward to aft through every stage.
1476///
1477/// Each automatic radius has a source: a nose cone's base and a transition's aft radius take the
1478/// next component's forward radius; a body tube and a transition's forward radius take the previous
1479/// component's aft radius. Sources are followed until nothing changes. Then a body tube still
1480/// unresolved takes the next component's forward radius instead (the first one that can), and the
1481/// sweep repeats.
1482fn sweep_body_radii<P: std::borrow::Borrow<Part>>(
1483    parts: &[P],
1484    autos: &[&[AutoDimension]],
1485) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
1486    let n = parts.len();
1487    let is_auto = |i: usize, a: AutoDimension| autos[i].contains(&a);
1488    let mut fore: Vec<Option<f64>> = Vec::with_capacity(n);
1489    let mut aft: Vec<Option<f64>> = Vec::with_capacity(n);
1490    for (i, part) in parts.iter().enumerate() {
1491        let (f, a) = match part.borrow() {
1492            Part::NoseCone(p) => (
1493                Some(0.0),
1494                (!is_auto(i, AutoDimension::BaseRadius)).then_some(p.base_radius_m),
1495            ),
1496            Part::BodyTube(p) => {
1497                let r = (!is_auto(i, AutoDimension::OuterRadius)).then_some(p.outer_radius_m);
1498                (r, r)
1499            }
1500            Part::Transition(p) => (
1501                (!is_auto(i, AutoDimension::ForeRadius)).then_some(p.fore_radius_m),
1502                (!is_auto(i, AutoDimension::AftRadius)).then_some(p.aft_radius_m),
1503            ),
1504            _ => (None, None),
1505        };
1506        fore.push(f);
1507        aft.push(a);
1508    }
1509    loop {
1510        let mut progress = false;
1511        for i in 0..n {
1512            let previous_aft = if i > 0 { aft[i - 1] } else { None };
1513            let next_fore = fore.get(i + 1).copied().flatten();
1514            match parts[i].borrow() {
1515                Part::NoseCone(_) => {
1516                    if aft[i].is_none()
1517                        && let Some(r) = next_fore
1518                    {
1519                        aft[i] = Some(r);
1520                        progress = true;
1521                    }
1522                }
1523                Part::BodyTube(_) => {
1524                    if fore[i].is_none()
1525                        && let Some(r) = previous_aft
1526                    {
1527                        fore[i] = Some(r);
1528                        aft[i] = Some(r);
1529                        progress = true;
1530                    }
1531                }
1532                Part::Transition(_) => {
1533                    if fore[i].is_none()
1534                        && let Some(r) = previous_aft
1535                    {
1536                        fore[i] = Some(r);
1537                        progress = true;
1538                    }
1539                    if aft[i].is_none()
1540                        && let Some(r) = next_fore
1541                    {
1542                        aft[i] = Some(r);
1543                        progress = true;
1544                    }
1545                }
1546                _ => {}
1547            }
1548        }
1549        if progress {
1550            continue;
1551        }
1552        let fallback = (0..n).find(|&i| {
1553            matches!(parts[i].borrow(), Part::BodyTube(_))
1554                && fore[i].is_none()
1555                && fore.get(i + 1).copied().flatten().is_some()
1556        });
1557        match fallback {
1558            Some(i) => {
1559                fore[i] = fore[i + 1];
1560                aft[i] = fore[i + 1];
1561            }
1562            None => break,
1563        }
1564    }
1565    (fore, aft)
1566}
1567
1568/// Fills in automatic shoulder radii from the adjoining body tubes' inner radii.
1569fn resolve_shoulders(
1570    parts: &mut [Part],
1571    autos: &[&[AutoDimension]],
1572    ids: &[&str],
1573) -> Result<(), DesignError> {
1574    let inner = |parts: &[Part], i: Option<usize>| -> Option<f64> {
1575        match i.and_then(|i| parts.get(i)) {
1576            Some(Part::BodyTube(t)) => Some(t.outer_radius_m - t.thickness_m),
1577            _ => None,
1578        }
1579    };
1580    for i in 0..parts.len() {
1581        let previous = inner(parts, i.checked_sub(1));
1582        let next = inner(parts, Some(i + 1));
1583        let fit = |shoulder: &mut Option<crate::parts::Shoulder>,
1584                   radius: Option<f64>,
1585                   which: &str|
1586         -> Result<(), DesignError> {
1587            let Some(shoulder) = shoulder else {
1588                return Err(tree(
1589                    ids[i],
1590                    format!("an automatic {which} shoulder radius needs a shoulder"),
1591                ));
1592            };
1593            shoulder.outer_radius_m = radius.ok_or_else(|| {
1594                tree(
1595                    ids[i],
1596                    format!("an automatic {which} shoulder radius needs a body tube there"),
1597                )
1598            })?;
1599            Ok(())
1600        };
1601        match &mut parts[i] {
1602            Part::NoseCone(p) if autos[i].contains(&AutoDimension::ShoulderRadius) => {
1603                fit(&mut p.shoulder, next, "aft")?;
1604            }
1605            Part::Transition(p) => {
1606                if autos[i].contains(&AutoDimension::ForeShoulderRadius) {
1607                    fit(&mut p.fore_shoulder, previous, "forward")?;
1608                }
1609                if autos[i].contains(&AutoDimension::AftShoulderRadius) {
1610                    fit(&mut p.aft_shoulder, next, "aft")?;
1611                }
1612            }
1613            _ => {}
1614        }
1615    }
1616    Ok(())
1617}
1618
1619/// A part's mass properties placed with its forward end at `fore_station_m`.
1620fn place(
1621    part: &Part,
1622    body_radius_m: Option<f64>,
1623    fore_station_m: f64,
1624    id: &str,
1625) -> Result<MassProperties, DesignError> {
1626    let mass = part
1627        .mass_properties(body_radius_m)
1628        .map_err(|e| within(id, e))?;
1629    Ok(mass.translated(DVec3::new(0.0, 0.0, -fore_station_m)))
1630}
1631
1632/// The body a mass override rescales: `mass` itself, but for a packed part under a mass override,
1633/// which becomes that mass as a solid cylinder of its packing. For a part that weighs something this
1634/// is the rescaling [`Overrides::apply`] does anyway; for one that weighs nothing it replaces the
1635/// point `apply` makes of any other weightless body. OpenRocket 24.12 does the
1636/// same: on probes of a parachute, a mass component and a shock cord each weighing nothing, its
1637/// roll inertia is the override's `m r²/2` over the packing's radius `r`, and its pitch inertia and
1638/// center are the cylinder's ([ADR-063][adr-063]). `fore_station_m` is the part's forward end.
1639///
1640/// [adr-063]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-063-packed-parts-read-and-weighed-as-openrocket-packs-them-2026-09-21
1641fn packed_for_override(
1642    part: &Part,
1643    overrides: &Overrides,
1644    mass: MassProperties,
1645    fore_station_m: f64,
1646) -> Result<MassProperties, DesignError> {
1647    match (overrides.mass_kg, part.packing()) {
1648        // A packed part of any mass is its packing's cylinder, so building the cylinder of `m′`
1649        // directly is what rescaling gives, and holds for a weightless (or subnormal) mass too.
1650        (Some(mass_kg), Some(packing)) => {
1651            check_dimension("mass override (kg)", mass_kg, true)?;
1652            Ok(packing
1653                .place(mass_kg)?
1654                .translated(DVec3::new(0.0, 0.0, -fore_station_m)))
1655        }
1656        _ => Ok(mass),
1657    }
1658}
1659
1660/// The parallel stages as [`finish`] places them: each as the pod set it is laid out as, under
1661/// the body tube it hangs on, and what each weighed and spanned once placed.
1662#[derive(Default)]
1663struct Hung {
1664    /// The parallel stages hung on each body tube, by the tube's id: each stage's index and its
1665    /// pod set.
1666    stages: BTreeMap<String, Vec<(usize, Component)>>,
1667    /// Each placed parallel stage's index, mass with its children, and fore and aft stations.
1668    placed: Vec<(usize, MassProperties, (f64, f64))>,
1669}
1670
1671/// The pod set parallel stage `stage` is laid out as: under the stage's id, at its position, its
1672/// body components the pods' stack. It carries no overrides of its own; the stage's apply to the
1673/// stage's mass ([`Rocket::staged_layout`]).
1674fn parallel_pods(stage: &Stage, parallel: &ParallelStage) -> Component {
1675    Component {
1676        id: stage.id.clone(),
1677        name: stage.name.clone(),
1678        part: Part::PodSet(parallel.pods.clone()),
1679        position: Some(parallel.position),
1680        auto: Vec::new(),
1681        motor_mount: None,
1682        finish: None,
1683        overrides: Overrides::default(),
1684        overrides_include_children: false,
1685        drag_override: None,
1686        children: stage.components.clone(),
1687    }
1688}
1689
1690/// Where a parallel stage hangs: the stage and the place in its component list of the body tube.
1691#[derive(Debug, Clone, Copy)]
1692struct Carrier {
1693    stage: usize,
1694    component: usize,
1695}
1696
1697impl Rocket {
1698    /// Where each stage hangs ([`ParallelStage::on`]): `None` for an axial stage. One pass, with
1699    /// the body tubes of the axial stages before each stage looked up by id.
1700    ///
1701    /// # Errors
1702    ///
1703    /// [`DesignError::Tree`] for a parallel stage whose tube is not a body tube among the body
1704    /// components of an axial stage before it.
1705    fn carriers(&self) -> Result<Vec<Option<Carrier>>, DesignError> {
1706        let mut tubes: BTreeMap<&str, Carrier> = BTreeMap::new();
1707        let mut carriers = Vec::with_capacity(self.stages.len());
1708        for (k, stage) in self.stages.iter().enumerate() {
1709            let Some(parallel) = &stage.parallel else {
1710                for (component, c) in stage.components.iter().enumerate() {
1711                    if matches!(c.part, Part::BodyTube(_)) {
1712                        tubes.entry(&c.id).or_insert(Carrier {
1713                            stage: k,
1714                            component,
1715                        });
1716                    }
1717                }
1718                carriers.push(None);
1719                continue;
1720            };
1721            let carrier = tubes.get(parallel.on.as_str()).copied().ok_or_else(|| {
1722                tree(
1723                    &stage.id,
1724                    format!(
1725                        "a parallel stage hangs on a body tube of an axial stage before it, and \
1726                         `{}` is none",
1727                        parallel.on
1728                    ),
1729                )
1730            })?;
1731            carriers.push(Some(carrier));
1732        }
1733        Ok(carriers)
1734    }
1735}
1736
1737/// Places a component's children, applies its overrides, and returns it with its children.
1738///
1739/// Masses are worked one copy at a time: a part inside a cluster is weighed, and its overrides
1740/// applied, as one part in one tube, and only then repeated in every tube ([ADR-075][adr-075]).
1741/// So an override on an engine block in a cluster is each block's, and an override on the cluster
1742/// tube itself is the whole cluster's, since that part is all its tubes. What is stored in
1743/// `components[index]` is every copy; what is returned is one copy of the part with its children.
1744///
1745/// [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
1746fn finish(
1747    components: &mut Vec<PlacedComponent>,
1748    index: usize,
1749    node: &Component,
1750    hung: &mut Hung,
1751) -> Result<MassProperties, DesignError> {
1752    // The parallel stages that hang on this part come after its own children, each tagged with its
1753    // own stage (`Some(k)`), its mass that stage's rather than this part's.
1754    let hanging: Vec<(usize, Component)> = hung.stages.get(&node.id).cloned().unwrap_or_default();
1755    let children: Vec<(&Component, Option<usize>)> = node
1756        .children
1757        .iter()
1758        .map(|child| (child, None))
1759        .chain(hanging.iter().map(|(k, pods)| (pods, Some(*k))))
1760        .collect();
1761    let parent = &components[index];
1762    let (p_fore, p_length, stage) = (parent.fore_station_m, parent.length_m, parent.stage);
1763    let (p_kind, p_inner) = (parent.part.kind_name(), parent.part.inner_radius_m());
1764    let p_axis = parent.part.axis_offset_m();
1765    // A pod set's children are its pod's body components: they stack along the pod.
1766    let pod = matches!(parent.part, Part::PodSet(_));
1767    // What this part holds is repeated in each of its tubes when it is a cluster, and in each pod
1768    // when it is a pod set: `p_tubes` for one copy of this part, `p_contents` for all of them.
1769    let p_tubes = repeats(&parent.part).map_err(|e| within(&node.id, e))?;
1770    let p_copies = parent.copies.clone();
1771    let p_contents = parent.contents_copies().map_err(|e| within(&node.id, e))?;
1772    let p_tube_radius = match &parent.part {
1773        Part::BodyTube(tube) => Some(tube.outer_radius_m),
1774        _ => None,
1775    };
1776    // A nose cone's or transition's outer surface, which a fin set's root follows (ADR-166).
1777    let p_surface = match &parent.part {
1778        Part::NoseCone(nose) => Some(nose.profile()),
1779        Part::Transition(part) => Some(part.profile()),
1780        _ => None,
1781    }
1782    .transpose()
1783    .map_err(|e| within(&node.id, e))?;
1784    // A nose cone's or transition's bore narrows along it: its outer profile and its wall, when it
1785    // has one. A solid one has no bore.
1786    let shell = |wall: &Wall| match wall {
1787        Wall::Shell { thickness_m } => Some(*thickness_m),
1788        Wall::Filled {} => None,
1789    };
1790    let p_narrowing = match &parent.part {
1791        Part::NoseCone(nose) => shell(&nose.wall).map(|t| nose.profile().map(|p| (p, t))),
1792        Part::Transition(part) => shell(&part.wall).map(|t| part.profile().map(|p| (p, t))),
1793        _ => None,
1794    }
1795    .transpose()
1796    .map_err(|e| within(&node.id, e))?;
1797    let own = if node.overrides_include_children {
1798        parent.own
1799    } else {
1800        packed_for_override(&parent.part, &node.overrides, parent.own, p_fore)
1801            .and_then(|own| node.overrides.apply(own, p_fore))
1802            .map_err(|e| within(&node.id, e))?
1803    };
1804
1805    // Positions first: they don't depend on any automatic radius, and a ring's inner radius needs
1806    // its siblings' places.
1807    let mut stations = Vec::with_capacity(children.len());
1808    let mut previous_aft = None;
1809    for &(child, _) in &children {
1810        let length = child.length_m();
1811        check_dimension("attached part length", length, true).map_err(|e| within(&child.id, e))?;
1812        let fore = if pod {
1813            previous_aft.unwrap_or(p_fore)
1814        } else {
1815            match child.position {
1816                Some(Position::Top { aft_offset_m }) => p_fore + aft_offset_m,
1817                Some(Position::Middle { aft_offset_m }) => {
1818                    p_fore + 0.5 * (p_length - length) + aft_offset_m
1819                }
1820                Some(Position::Bottom { aft_offset_m }) => {
1821                    p_fore + p_length - length + aft_offset_m
1822                }
1823                Some(Position::After { aft_offset_m }) => {
1824                    previous_aft.unwrap_or(p_fore) + aft_offset_m
1825                }
1826                Some(Position::Absolute { station_m }) => station_m,
1827                None => return Err(tree(&child.id, "an attached part needs a position")),
1828            }
1829        };
1830        if !fore.is_finite() {
1831            return Err(within(
1832                &child.id,
1833                DesignError::Domain {
1834                    what: "attached part position (m)",
1835                    value: fore,
1836                },
1837            ));
1838        }
1839        stations.push((fore, length));
1840        previous_aft = Some(fore + length);
1841    }
1842
1843    let bore = |id: &str| {
1844        p_inner.ok_or_else(|| {
1845            tree(
1846                id,
1847                format!("an automatic radius needs a tube's inner radius, and a {p_kind} has none"),
1848            )
1849        })
1850    };
1851    // An automatic outer radius inside a nose cone or transition is its bore at whichever end of
1852    // the part is narrower: the outer radius there less the wall, measured radially, and none
1853    // where the wall meets the axis. Where the part runs past an end of its parent, the profile's
1854    // radius at that end stands. This is OpenRocket 24.12's reading, measured on probe designs
1855    // (`validation/oracles/openrocket/conventions.py`, [ADR-096][adr-096]).
1856    //
1857    // [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
1858    let bore_over = |id: &str, (fore, length): (f64, f64)| match &p_narrowing {
1859        Some((profile, wall_m)) => {
1860            let at = |station: f64| profile.radius_m(station - p_fore);
1861            Ok((at(fore).min(at(fore + length)) - wall_m).max(0.0))
1862        }
1863        None => bore(id),
1864    };
1865    // Automatic outer radii come first, in a pass of their own. They need nothing but the parent's
1866    // bore, while a ring's automatic *inner* radius reads its siblings' outer radii, so resolving
1867    // both in one pass would give a ring whose bore depended on whether the tube inside it was
1868    // written before or after it. That is [Loft lesson L60][lessons], and the reason this is two
1869    // passes rather than one.
1870    //
1871    // [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
1872    let mut resolved: Vec<Part> = children.iter().map(|(c, _)| c.part.clone()).collect();
1873    if pod {
1874        // A pod's body components take their automatic radii from one another, as a stage's do.
1875        let autos: Vec<&[AutoDimension]> =
1876            node.children.iter().map(|c| c.auto.as_slice()).collect();
1877        let ids: Vec<&str> = node.children.iter().map(|c| c.id.as_str()).collect();
1878        resolve_body_radii(&mut resolved, &autos, &ids)?;
1879        resolve_shoulders(&mut resolved, &autos, &ids)?;
1880    }
1881    for ((part, &(child, _)), &station) in resolved.iter_mut().zip(&children).zip(&stations) {
1882        if child.auto.contains(&AutoDimension::OuterRadius) {
1883            match part {
1884                Part::CenteringRing(ring) => ring.outer_radius_m = bore_over(&child.id, station)?,
1885                // A wall thicker than the radius it gets is the tube solid, as it is when the
1886                // radius is stated (`hpr-io`'s `.ork` reader, and OpenRocket 24.12 on probes).
1887                Part::InnerTube(tube) => {
1888                    tube.outer_radius_m = bore_over(&child.id, station)?;
1889                    tube.thickness_m = tube.thickness_m.min(tube.outer_radius_m);
1890                }
1891                // Tubes that close the ring around the body tube they sit on, a wall thicker than
1892                // that radius cut to it, as OpenRocket 24.12 reads both (ADR-098). An external part
1893                // on anything but a body tube is refused before this, so the error is a backstop.
1894                Part::TubeFinSet(tubes) => {
1895                    let body_radius_m = p_tube_radius.ok_or_else(|| {
1896                        tree(
1897                            &child.id,
1898                            "an automatic tube fin radius needs a body tube to ring",
1899                        )
1900                    })?;
1901                    tubes.outer_radius_m =
1902                        crate::TubeFinSet::closing_radius_m(body_radius_m, tubes.count);
1903                    tubes.thickness_m = tubes.thickness_m.min(tubes.outer_radius_m);
1904                }
1905                _ => {}
1906            }
1907        }
1908    }
1909
1910    let mut parts = vec![own];
1911    for (k, &(child, hung_stage)) in children.iter().enumerate() {
1912        let (fore, length) = stations[k];
1913        let mut part = resolved[k].clone();
1914        let parent_inner = || bore(&child.id);
1915        // A packed part fills the parent's bore on its side of the parent's axis.
1916        let packed = |packing: &mut crate::parts::Packing| -> Result<(), DesignError> {
1917            let (x, y) = (
1918                packing.radial_offset_m * packing.angle_rad.cos(),
1919                packing.radial_offset_m * packing.angle_rad.sin(),
1920            );
1921            let room = parent_inner()? - (x - p_axis[0]).hypot(y - p_axis[1]);
1922            if !(room.is_finite() && room > 0.0) {
1923                return Err(tree(
1924                    &child.id,
1925                    "an automatic packed radius needs a radial offset inside the parent's bore",
1926                ));
1927            }
1928            packing.radius_m = room;
1929            Ok(())
1930        };
1931        for auto in &child.auto {
1932            match (auto, &mut part) {
1933                // Already done in the pass above.
1934                (AutoDimension::OuterRadius, _) => {}
1935                (AutoDimension::InnerRadius, Part::CenteringRing(ring)) => {
1936                    let aft = fore + length;
1937                    // A ring centers something *narrower than itself*. A sibling as wide as the
1938                    // ring is not what the ring holds (it is whatever the ring is bolted to), and
1939                    // taking its radius would leave the ring no material at all, which is how a
1940                    // full-bore coupler brushing a ring by a tenth of a millimeter made the ring
1941                    // weigh nothing. The ring's own outer radius is already resolved above.
1942                    let outer_radius_m = ring.outer_radius_m;
1943                    ring.inner_radius_m = resolved
1944                        .iter()
1945                        .zip(&stations)
1946                        .filter_map(|(sibling, &(s_fore, s_length))| match sibling {
1947                            Part::InnerTube(tube)
1948                                if tube.radial_offset_m == 0.0
1949                                    && tube.outer_radius_m < outer_radius_m
1950                                    && s_fore.max(fore) < (s_fore + s_length).min(aft) =>
1951                            {
1952                                Some(tube.outer_radius_m)
1953                            }
1954                            _ => None,
1955                        })
1956                        .fold(0.0, f64::max);
1957                }
1958                (AutoDimension::PackedRadius, Part::MassComponent(p)) => packed(&mut p.packing)?,
1959                (AutoDimension::PackedRadius, Part::Parachute(p)) => packed(&mut p.packing)?,
1960                (AutoDimension::PackedRadius, Part::Streamer(p)) => packed(&mut p.packing)?,
1961                (AutoDimension::PackedRadius, Part::ShockCord(p)) => packed(&mut p.packing)?,
1962                _ => {}
1963            }
1964        }
1965        // A fin set's root may follow a nose cone or a transition (ADR-166); on a body tube it is
1966        // level.
1967        let body_radius_m = match (&part, &p_surface) {
1968            (Part::FinSet(fins), Some(surface)) => Some(
1969                fins.root_radius_on(surface, fore - p_fore)
1970                    .map_err(|e| within(&child.id, e))?,
1971            ),
1972            _ if part.is_external() => {
1973                if let Part::FinSet(fins) = &part {
1974                    fins.check_level_root().map_err(|e| within(&child.id, e))?;
1975                }
1976                Some(
1977                    p_tube_radius
1978                        .ok_or_else(|| tree(&child.id, "external parts attach to a body tube"))?,
1979                )
1980            }
1981            _ => None,
1982        };
1983        // One copy: `finish` applies the child's overrides to it and then repeats it.
1984        let placed = place(&part, body_radius_m, fore, &child.id)?;
1985        let child_index = components.len();
1986        components.push(PlacedComponent {
1987            id: child.id.clone(),
1988            stage: hung_stage.unwrap_or(stage),
1989            parent: Some(index),
1990            part,
1991            fore_station_m: fore,
1992            length_m: length,
1993            finish: child.finish.unwrap_or_default(),
1994            body_radius_m,
1995            motor_mount: child.motor_mount,
1996            own: placed,
1997            with_children: placed,
1998            center_overridden: child.overrides.sets_axial_center(),
1999            copies: p_contents.clone(),
2000            drag_override: child.drag_override,
2001        });
2002        let mass = MassProperties::placed(finish(components, child_index, child, hung)?, &p_tubes);
2003        match hung_stage {
2004            Some(k) => hung.placed.push((k, mass, (fore, fore + length))),
2005            None => parts.push(mass),
2006        }
2007    }
2008
2009    let mut with_children = MassProperties::combine(&parts);
2010    if node.overrides_include_children {
2011        with_children = packed_for_override(
2012            &components[index].part,
2013            &node.overrides,
2014            with_children,
2015            p_fore,
2016        )
2017        .and_then(|mass| node.overrides.apply(mass, p_fore))
2018        .map_err(|e| within(&node.id, e))?;
2019    }
2020    components[index].own = MassProperties::placed(own, &p_copies);
2021    components[index].with_children = MassProperties::placed(with_children, &p_copies);
2022    Ok(with_children)
2023}
2024
2025#[cfg(test)]
2026mod tests {
2027    use proptest::prelude::*;
2028
2029    use super::*;
2030    use crate::parts::Shoulder;
2031    use crate::testing::{
2032        attached, body, bottom, fins, inner_tube, mass_component, nose, ring, rocket, stage,
2033        three_fin_rocket, top, tube,
2034    };
2035
2036    fn close(got: f64, want: f64, tol: f64, what: &str) {
2037        assert!((got - want).abs() <= tol, "{what}: {got} vs {want}");
2038    }
2039
2040    fn station(layout: &Layout, id: &str) -> f64 {
2041        layout.find(id).unwrap().1.fore_station_m
2042    }
2043
2044    /// Body components stack through both stages; each position rule puts a child where its doc
2045    /// says, worked by hand from the parent tube at stations 0.2 to 1.0.
2046    #[test]
2047    fn stacks_body_components_and_places_attached_parts() {
2048        let mut airframe = body("airframe", tube(0.8, 0.03, 0.001));
2049        let mut coupler = attached("coupler", inner_tube(0.2, 0.029, 0.001), top(0.1));
2050        coupler.children = vec![attached(
2051            "bay",
2052            mass_component(0.1, 0.05, 0.02),
2053            Position::After { aft_offset_m: 0.02 },
2054        )];
2055        airframe.children = vec![
2056            coupler,
2057            attached(
2058                "middle",
2059                mass_component(0.1, 0.1, 0.02),
2060                Position::Middle { aft_offset_m: 0.02 },
2061            ),
2062            attached("bottom", mass_component(0.1, 0.1, 0.02), bottom(-0.01)),
2063            attached(
2064                "after",
2065                mass_component(0.1, 0.004, 0.02),
2066                Position::After {
2067                    aft_offset_m: 0.001,
2068                },
2069            ),
2070            attached(
2071                "absolute",
2072                mass_component(0.1, 0.05, 0.02),
2073                Position::Absolute { station_m: 0.4 },
2074            ),
2075        ];
2076        let design = rocket(vec![
2077            stage("upper", vec![body("nose", nose(0.2, 0.03)), airframe]),
2078            stage(
2079                "booster",
2080                vec![
2081                    body(
2082                        "interstage",
2083                        Part::Transition(Transition {
2084                            shape: crate::NoseShape::Conical {},
2085                            clipped: false,
2086                            length_m: 0.1,
2087                            fore_radius_m: 0.03,
2088                            aft_radius_m: 0.04,
2089                            wall: crate::Wall::Shell { thickness_m: 0.002 },
2090                            fore_shoulder: None,
2091                            aft_shoulder: None,
2092                            material: crate::testing::cardboard(),
2093                        }),
2094                    ),
2095                    body("booster-tube", tube(0.5, 0.04, 0.001)),
2096                ],
2097            ),
2098        ]);
2099        let layout = design.layout().unwrap();
2100        let tol = 1e-15;
2101        close(station(&layout, "nose"), 0.0, tol, "nose");
2102        close(station(&layout, "airframe"), 0.2, tol, "airframe");
2103        close(station(&layout, "coupler"), 0.3, tol, "top");
2104        // The first child of the coupler goes after the coupler's forward end.
2105        close(station(&layout, "bay"), 0.32, tol, "after, first child");
2106        close(station(&layout, "middle"), 0.2 + 0.35 + 0.02, tol, "middle");
2107        close(station(&layout, "bottom"), 1.0 - 0.1 - 0.01, tol, "bottom");
2108        close(station(&layout, "after"), 0.99 + 0.001, tol, "after");
2109        close(station(&layout, "absolute"), 0.4, tol, "absolute");
2110        close(station(&layout, "interstage"), 1.0, tol, "second stage");
2111        close(station(&layout, "booster-tube"), 1.1, tol, "booster tube");
2112        close(layout.length_m, 1.6, tol, "length");
2113        let [upper, booster] = &layout.stages[..] else {
2114            panic!("two stages")
2115        };
2116        close(upper.fore_station_m, 0.0, tol, "upper fore");
2117        close(upper.aft_station_m, 1.0, tol, "upper aft");
2118        close(booster.fore_station_m, 1.0, tol, "booster fore");
2119        close(booster.aft_station_m, 1.6, tol, "booster aft");
2120        let (bay, placed) = layout.find("bay").unwrap();
2121        assert_eq!(
2122            layout.components[placed.parent.unwrap()].id,
2123            "coupler",
2124            "parent"
2125        );
2126        assert_eq!(layout.components[bay].stage, 0);
2127        assert_eq!(layout.find("booster-tube").unwrap().1.stage, 1);
2128        // A placed part's center is its own-frame center moved to its station.
2129        let own = mass_component(0.1, 0.05, 0.02)
2130            .mass_properties(None)
2131            .unwrap();
2132        close(placed.own.cg_m.z, own.cg_m.z - 0.32, 1e-15, "placed center");
2133        // The structure is every stage, and each stage every component with its children.
2134        let sum: f64 = layout.body().map(|c| c.with_children.mass_kg).sum();
2135        close(layout.structure.mass_kg, sum, 1e-15, "structure mass");
2136    }
2137
2138    /// M4.5g4 (ADR-166): a fin set sits on a nose cone with its root along the surface, its body
2139    /// radius the surface's at the root leading edge. A conical nose 0.2 m long of base radius
2140    /// 0.03 m has `r = 0.15 x`: a 0.05 m root from its aft end starts at radius 0.0225 m and
2141    /// rises 0.0075 m. On a body tube a root that rises is refused, a level root on the cone
2142    /// stands off its surface, and a launch lug still attaches only to a body tube.
2143    #[test]
2144    fn a_fin_set_on_a_nose_cone_follows_its_surface() {
2145        let fin = |end_h: f64| {
2146            let mut set = match fins(0.05, 0.04) {
2147                Part::FinSet(set) => set,
2148                _ => unreachable!("`fins` makes a fin set"),
2149            };
2150            set.planform = crate::FinPlanform::Freeform {
2151                points_m: vec![[0.0, 0.0], [0.03, 0.04], [0.05, end_h]],
2152                root_m: Vec::new(),
2153            };
2154            set
2155        };
2156        let design = |child: Component, on_the_nose: bool| {
2157            let mut nose_cone = body("nose", nose(0.2, 0.03));
2158            let mut airframe = body("airframe", tube(0.6, 0.03, 0.001));
2159            if on_the_nose {
2160                nose_cone.children = vec![child];
2161            } else {
2162                airframe.children = vec![child];
2163            }
2164            rocket(vec![stage("only", vec![nose_cone, airframe])])
2165        };
2166        let layout = design(
2167            attached("cockpit", Part::FinSet(fin(0.0075)), bottom(0.0)),
2168            true,
2169        )
2170        .layout()
2171        .unwrap();
2172        let (_, placed) = layout.find("cockpit").unwrap();
2173        close(placed.fore_station_m, 0.15, 1e-15, "root leading edge");
2174        close(placed.body_radius_m.unwrap(), 0.0225, 1e-15, "body radius");
2175        close(
2176            placed.own.mass_kg,
2177            fin(0.0075).mass_properties(0.0225).unwrap().mass_kg,
2178            1e-15,
2179            "mass",
2180        );
2181        let refused = |child: Component, on_the_nose: bool, says: &str| {
2182            let err = design(child, on_the_nose).layout().unwrap_err().to_string();
2183            assert!(err.contains(says), "{says}: {err}");
2184        };
2185        refused(
2186            attached("rising", Part::FinSet(fin(0.0075)), bottom(0.0)),
2187            false,
2188            "needs a level root",
2189        );
2190        refused(
2191            attached("level", fins(0.05, 0.04), bottom(0.0)),
2192            true,
2193            "stands",
2194        );
2195        refused(
2196            attached("short", Part::FinSet(fin(0.0075)), bottom(0.01)),
2197            true,
2198            "must stay on it",
2199        );
2200        let lug = Part::LaunchLug(crate::LaunchLug {
2201            length_m: 0.03,
2202            outer_radius_m: 0.003,
2203            thickness_m: 0.0005,
2204            angle_rad: 0.0,
2205            count: 1,
2206            spacing_m: 0.0,
2207            material: crate::testing::cardboard(),
2208        });
2209        refused(
2210            attached("lug", lug, bottom(0.0)),
2211            true,
2212            "a launch_lug attaches to a body tube, not a nose_cone",
2213        );
2214    }
2215
2216    /// Automatic radii: a nose takes the tube behind it, a transition's forward radius crosses a
2217    /// stage boundary, a tube takes the transition ahead of it, and a tube with nothing resolvable
2218    /// ahead takes the next fixed radius.
2219    #[test]
2220    fn automatic_radii_follow_neighbours_across_stages() {
2221        let auto = |mut c: Component, dims: &[AutoDimension]| {
2222            c.auto = dims.to_vec();
2223            c
2224        };
2225        let transition = Part::Transition(Transition {
2226            shape: crate::NoseShape::Conical {},
2227            clipped: false,
2228            length_m: 0.1,
2229            fore_radius_m: 0.0,
2230            aft_radius_m: 0.04,
2231            wall: crate::Wall::Filled {},
2232            fore_shoulder: Some(Shoulder {
2233                length_m: 0.05,
2234                outer_radius_m: 0.0,
2235                thickness_m: 0.002,
2236                capped: false,
2237            }),
2238            aft_shoulder: None,
2239            material: crate::testing::cardboard(),
2240        });
2241        let mut nose_cone = auto(body("nose", nose(0.2, 0.0)), &[AutoDimension::BaseRadius]);
2242        if let Part::NoseCone(n) = &mut nose_cone.part {
2243            n.shoulder = Some(Shoulder {
2244                length_m: 0.05,
2245                outer_radius_m: 0.0,
2246                thickness_m: 0.002,
2247                capped: true,
2248            });
2249        }
2250        nose_cone.auto.push(AutoDimension::ShoulderRadius);
2251        let design = rocket(vec![
2252            stage(
2253                "upper",
2254                vec![nose_cone, body("upper-tube", tube(0.5, 0.03, 0.001))],
2255            ),
2256            stage(
2257                "booster",
2258                vec![
2259                    auto(
2260                        body("interstage", transition),
2261                        &[AutoDimension::ForeRadius, AutoDimension::ForeShoulderRadius],
2262                    ),
2263                    auto(
2264                        body("booster-tube", tube(0.5, 0.0, 0.001)),
2265                        &[AutoDimension::OuterRadius],
2266                    ),
2267                ],
2268            ),
2269        ]);
2270        let layout = design.layout().unwrap();
2271        let part = |id: &str| layout.find(id).unwrap().1.part.clone();
2272        let Part::NoseCone(n) = part("nose") else {
2273            panic!()
2274        };
2275        assert_eq!(n.base_radius_m, 0.03);
2276        assert_eq!(n.shoulder.unwrap().outer_radius_m, 0.03 - 0.001);
2277        let Part::Transition(t) = part("interstage") else {
2278            panic!()
2279        };
2280        assert_eq!(t.fore_radius_m, 0.03);
2281        assert_eq!(t.fore_shoulder.unwrap().outer_radius_m, 0.03 - 0.001);
2282        let Part::BodyTube(b) = part("booster-tube") else {
2283            panic!()
2284        };
2285        assert_eq!(b.outer_radius_m, 0.04);
2286
2287        // Nothing ahead of the first tube is fixed, so it takes the tube behind it.
2288        let design = rocket(vec![stage(
2289            "only",
2290            vec![
2291                auto(body("nose", nose(0.2, 0.0)), &[AutoDimension::BaseRadius]),
2292                auto(
2293                    body("a", tube(0.3, 0.0, 0.001)),
2294                    &[AutoDimension::OuterRadius],
2295                ),
2296                body("b", tube(0.3, 0.05, 0.001)),
2297            ],
2298        )]);
2299        let layout = design.layout().unwrap();
2300        assert_eq!(layout.find("a").unwrap().1.part.aft_radius_m(), Some(0.05));
2301        assert_eq!(
2302            layout.find("nose").unwrap().1.part.aft_radius_m(),
2303            Some(0.05)
2304        );
2305        // A fixed radius ahead wins over one behind.
2306        let design = rocket(vec![stage(
2307            "only",
2308            vec![
2309                body("nose", nose(0.2, 0.02)),
2310                auto(
2311                    body("a", tube(0.3, 0.0, 0.001)),
2312                    &[AutoDimension::OuterRadius],
2313                ),
2314                body("b", tube(0.3, 0.05, 0.001)),
2315            ],
2316        )]);
2317        let layout = design.layout().unwrap();
2318        assert_eq!(layout.find("a").unwrap().1.part.aft_radius_m(), Some(0.02));
2319
2320        // Automatic all the way round has nothing to take.
2321        let design = rocket(vec![stage(
2322            "only",
2323            vec![
2324                auto(body("nose", nose(0.2, 0.0)), &[AutoDimension::BaseRadius]),
2325                auto(
2326                    body("a", tube(0.3, 0.0, 0.001)),
2327                    &[AutoDimension::OuterRadius],
2328                ),
2329            ],
2330        )]);
2331        assert!(matches!(
2332            design.layout(),
2333            Err(DesignError::Tree { ref id, .. }) if id == "nose"
2334        ));
2335    }
2336
2337    /// A conical transition with a 1 mm wall.
2338    fn cone_transition(fore_radius_m: f64, aft_radius_m: f64) -> Part {
2339        Part::Transition(Transition {
2340            shape: crate::NoseShape::Conical {},
2341            clipped: false,
2342            length_m: 0.1,
2343            fore_radius_m,
2344            aft_radius_m,
2345            wall: crate::Wall::Shell { thickness_m: 0.001 },
2346            fore_shoulder: None,
2347            aft_shoulder: None,
2348            material: crate::testing::cardboard(),
2349        })
2350    }
2351
2352    /// The chain in Loft's quirks fixture: a nose's base, a tube and a transition's forward end all
2353    /// automatic, with only the transition's aft end fixed. Each automatic radius there looks at
2354    /// another automatic one, so all three are listed, forward to aft, and filling them is all
2355    /// `layout` needs. The fixed tube behind is never listed, and a radius the neighbour rule can
2356    /// reach is never filled.
2357    #[test]
2358    fn unresolvable_body_radii_are_the_chain_with_nothing_fixed() {
2359        let auto = |mut c: Component, dims: &[AutoDimension]| {
2360            c.auto = dims.to_vec();
2361            c
2362        };
2363        let mut design = rocket(vec![stage(
2364            "only",
2365            vec![
2366                auto(body("nose", nose(0.3, 0.0)), &[AutoDimension::BaseRadius]),
2367                auto(
2368                    body("upper", tube(0.5, 0.0, 0.002)),
2369                    &[AutoDimension::OuterRadius],
2370                ),
2371                auto(
2372                    body("shoulder", cone_transition(0.0, 0.022)),
2373                    &[AutoDimension::ForeRadius],
2374                ),
2375                body("lower", tube(0.45, 0.022, 0.0018)),
2376            ],
2377        )]);
2378        let listed = |design: &Rocket| -> Vec<(usize, usize, String, AutoDimension)> {
2379            design
2380                .unresolvable_body_radii()
2381                .into_iter()
2382                .map(|r| (r.stage, r.component, r.id, r.dimension))
2383                .collect()
2384        };
2385        let chain = vec![
2386            (0, 0, "nose".to_owned(), AutoDimension::BaseRadius),
2387            (0, 1, "upper".to_owned(), AutoDimension::OuterRadius),
2388            (0, 2, "shoulder".to_owned(), AutoDimension::ForeRadius),
2389        ];
2390        assert_eq!(listed(&design), chain);
2391        assert!(design.layout().is_err());
2392
2393        let filled = design.fill_unresolvable_body_radii(0.025);
2394        let filled: Vec<_> = filled
2395            .into_iter()
2396            .map(|r| (r.stage, r.component, r.id, r.dimension))
2397            .collect();
2398        assert_eq!(filled, chain);
2399        assert!(listed(&design).is_empty());
2400        assert!(
2401            design.stages[0]
2402                .components
2403                .iter()
2404                .all(|c| c.auto.is_empty())
2405        );
2406        let layout = design.layout().unwrap();
2407        let radius = |id: &str| layout.find(id).unwrap().1.part.aft_radius_m();
2408        assert_eq!(radius("nose"), Some(0.025));
2409        assert_eq!(radius("upper"), Some(0.025));
2410        let Part::Transition(t) = &layout.find("shoulder").unwrap().1.part else {
2411            panic!("a transition")
2412        };
2413        assert_eq!((t.fore_radius_m, t.aft_radius_m), (0.025, 0.022));
2414
2415        // A design that resolves lists nothing, and filling it changes nothing.
2416        let mut design = three_fin_rocket();
2417        let before = design.clone();
2418        assert!(design.unresolvable_body_radii().is_empty());
2419        assert!(design.fill_unresolvable_body_radii(0.025).is_empty());
2420        assert_eq!(design, before);
2421        design.layout().unwrap();
2422    }
2423
2424    /// Rings take the tube's bore and the mount tube's outside; the parachute packs to the bore; a
2425    /// ring beside no inner tube is a bulkhead.
2426    #[test]
2427    fn ring_and_packed_radii_come_from_parent_and_siblings() {
2428        let layout = three_fin_rocket().layout().unwrap();
2429        let part = |id: &str| layout.find(id).unwrap().1.part.clone();
2430        for id in ["ring-fore", "ring-aft"] {
2431            let Part::CenteringRing(r) = part(id) else {
2432                panic!()
2433            };
2434            assert_eq!(r.outer_radius_m, 0.027 - 0.0015, "{id}");
2435            assert_eq!(r.inner_radius_m, 0.020, "{id}");
2436        }
2437        let Part::Parachute(p) = part("chute") else {
2438            panic!()
2439        };
2440        assert_eq!(p.packing.radius_m, 0.027 - 0.0015);
2441
2442        let mut design = three_fin_rocket();
2443        let airframe = &mut design.stages[0].components[1];
2444        // Move the fore ring forward of the mount tube, which spans stations 0.7 to 1.0.
2445        airframe.children[1].position = Some(top(0.1));
2446        let layout = design.layout().unwrap();
2447        let Part::CenteringRing(r) = layout.find("ring-fore").unwrap().1.part.clone() else {
2448            panic!()
2449        };
2450        assert_eq!(r.inner_radius_m, 0.0);
2451    }
2452
2453    /// A tube fin set written `auto` closes the ring around the 27 mm airframe it sits on: six
2454    /// tubes as wide as the body, four `1 + √2` times it, and a wall thicker than that cut to it
2455    /// (ADR-098). It weighs what the same set of a stated radius weighs.
2456    #[test]
2457    fn an_automatic_tube_fin_radius_closes_the_ring_around_its_body() {
2458        let tubes = |count: u32, thickness_m: f64| {
2459            Part::TubeFinSet(crate::TubeFinSet {
2460                count,
2461                length_m: 0.1,
2462                outer_radius_m: 0.0,
2463                thickness_m,
2464                base_angle_rad: 0.0,
2465                material: crate::Material::bulk("cardboard", 680.0),
2466            })
2467        };
2468        let with = |part: Part, auto: bool, on: usize| {
2469            let mut design = three_fin_rocket();
2470            let mut child = attached("tubes", part, bottom(0.0));
2471            if auto {
2472                child.auto = vec![AutoDimension::OuterRadius];
2473            }
2474            design.stages[0].components[on].children.push(child);
2475            design.layout()
2476        };
2477        let resolved = |layout: &Layout| match &layout.find("tubes").unwrap().1.part {
2478            Part::TubeFinSet(set) => (set.outer_radius_m, set.thickness_m),
2479            other => panic!("{other:?}"),
2480        };
2481        let layout = with(tubes(6, 0.001), true, 1).unwrap();
2482        let (r, t) = resolved(&layout);
2483        close(r, 0.027, 1e-15, "six");
2484        assert_eq!(t, 0.001);
2485        let mut stated = tubes(6, 0.001);
2486        if let Part::TubeFinSet(set) = &mut stated {
2487            set.outer_radius_m = r;
2488        }
2489        let same = with(stated, false, 1).unwrap();
2490        assert_eq!(
2491            layout.find("tubes").unwrap().1.own,
2492            same.find("tubes").unwrap().1.own
2493        );
2494        let (r, _) = resolved(&with(tubes(4, 0.001), true, 1).unwrap());
2495        close(r, 0.027 * (1.0 + 2f64.sqrt()), 1e-15, "four");
2496        let (r, _) = resolved(&with(tubes(2, 0.001), true, 1).unwrap());
2497        assert_eq!(r, 0.027);
2498        let (r, t) = resolved(&with(tubes(6, 0.05), true, 1).unwrap());
2499        assert_eq!(t, r);
2500        // On the nose cone there is no body tube to ring, and the layout says so before it looks.
2501        let error = with(tubes(6, 0.001), true, 0).unwrap_err().to_string();
2502        assert!(error.contains("attaches to a body tube"), "{error}");
2503    }
2504
2505    /// Inside a hollow nose cone or transition, an automatic outer radius is the parent's bore at
2506    /// the narrower end of the part: the profile's radius there less the wall (ADR-096). The
2507    /// sample's nose is a 0.2 m cone on a 27 mm base with a 2 mm wall, so its radius at `x` from
2508    /// the tip is `0.135 x`.
2509    #[test]
2510    fn an_automatic_radius_inside_a_nose_is_its_bore_at_the_narrow_end() {
2511        let with = |part: Part, position: Position| {
2512            let mut design = three_fin_rocket();
2513            let mut child = attached("inside", part, position);
2514            child.auto = vec![AutoDimension::OuterRadius];
2515            design.stages[0].components[0].children = vec![child];
2516            design.layout()
2517        };
2518        let radius = |layout: &Layout| match &layout.find("inside").unwrap().1.part {
2519            Part::InnerTube(tube) => (tube.outer_radius_m, tube.thickness_m),
2520            Part::CenteringRing(ring) => (ring.outer_radius_m, ring.inner_radius_m),
2521            other => panic!("{other:?}"),
2522        };
2523        let coupler = || inner_tube(0.1, 0.0, 0.001);
2524        // Stations 0.1 to 0.2 from the tip: the fore end is the narrower.
2525        let (r, t) = radius(&with(coupler(), bottom(0.0)).unwrap());
2526        close(r, 0.135 * 0.1 - 0.002, 1e-17, "at the bottom");
2527        assert_eq!(t, 0.001);
2528        // Past the base, 0.15 to 0.25: still the fore end's.
2529        let (r, _) = radius(&with(coupler(), bottom(0.05)).unwrap());
2530        close(r, 0.135 * 0.15 - 0.002, 1e-17, "past the base");
2531        // A ring's outer radius the same way; its written bore stands.
2532        let (r, bore) = radius(&with(ring(0.006, 0.0, 0.005), bottom(0.0)).unwrap());
2533        close(r, 0.135 * 0.194 - 0.002, 1e-17, "a ring");
2534        assert_eq!(bore, 0.005);
2535        // A wall thicker than the radius it gets is the tube solid.
2536        let (r, t) = radius(&with(inner_tube(0.1, 0.0, 0.02), bottom(0.0)).unwrap());
2537        assert_eq!(t, r);
2538        // At the tip the wall meets the axis, and a tube of no radius is refused.
2539        let error = with(coupler(), top(0.0)).unwrap_err();
2540        assert!(
2541            matches!(
2542                &error,
2543                DesignError::InComponent { id, source }
2544                    if id == "inside"
2545                        && matches!(**source, DesignError::Domain { what: "outer radius", value } if value == 0.0)
2546            ),
2547            "{error:?}"
2548        );
2549        // A solid nose has no bore.
2550        let mut design = three_fin_rocket();
2551        if let Part::NoseCone(nose) = &mut design.stages[0].components[0].part {
2552            nose.wall = Wall::Filled {};
2553        }
2554        let mut child = attached("inside", coupler(), bottom(0.0));
2555        child.auto = vec![AutoDimension::OuterRadius];
2556        design.stages[0].components[0].children = vec![child];
2557        let error = design.layout().unwrap_err().to_string();
2558        assert!(error.contains("a nose_cone has none"), "{error}");
2559
2560        // A transition from 30 mm to 20 mm over 0.1 m narrows aft: a coupler over its first
2561        // 0.05 m takes the radius at its aft end, 25 mm, less the wall.
2562        let transition = Part::Transition(Transition {
2563            shape: crate::shapes::NoseShape::Conical {},
2564            clipped: false,
2565            length_m: 0.1,
2566            fore_radius_m: 0.03,
2567            aft_radius_m: 0.02,
2568            wall: Wall::Shell { thickness_m: 0.002 },
2569            fore_shoulder: None,
2570            aft_shoulder: None,
2571            material: crate::testing::cardboard(),
2572        });
2573        let mut part = body("transition", transition);
2574        let mut child = attached("inside", inner_tube(0.05, 0.0, 0.001), top(0.0));
2575        child.auto = vec![AutoDimension::OuterRadius];
2576        part.children = vec![child];
2577        let design = rocket(vec![stage(
2578            "only",
2579            vec![body("tube", tube(0.3, 0.03, 0.001)), part],
2580        )]);
2581        let (r, _) = radius(&design.layout().unwrap());
2582        close(r, 0.025 - 0.002, 1e-17, "a transition");
2583    }
2584
2585    /// What a clustered tube holds is in every tube: an engine block inside a 3-ring mount has
2586    /// three copies, one on each tube's axis, and the structure gains two more tubes and two more
2587    /// blocks than the unclustered mount with its block. A ring's automatic bore is the tube's own
2588    /// radius, as OpenRocket 24.12 gives it (ADR-075), so the tubes run through the ring and the
2589    /// checks say so, as they say the cluster reaches past the airframe's bore.
2590    #[test]
2591    fn a_cluster_repeats_what_it_holds_in_every_tube() {
2592        let three: Vec<[f64; 2]> = [90.0_f64, 210.0, 330.0]
2593            .iter()
2594            .map(|a| [0.02 * a.to_radians().cos(), 0.02 * a.to_radians().sin()])
2595            .collect();
2596        let with_block = |cluster_m: Vec<[f64; 2]>| {
2597            let mut design = crate::testing::three_fin_rocket();
2598            let mount = &mut design.stages[0].components[1].children[0];
2599            if let Part::InnerTube(tube) = &mut mount.part {
2600                tube.cluster_m = cluster_m;
2601            }
2602            mount.children = vec![attached("block", inner_tube(0.01, 0.019, 0.005), top(0.0))];
2603            design
2604        };
2605        let single = with_block(Vec::new()).layout().unwrap();
2606        let clustered_design = with_block(three.clone());
2607        let clustered = clustered_design.layout().unwrap();
2608        let (_, one) = single.find("block").unwrap();
2609        let (_, block) = clustered.find("block").unwrap();
2610        assert_eq!(one.copies, [Placement::HERE]);
2611        let moved: Vec<Placement> = three.iter().copied().map(Placement::moved).collect();
2612        assert_eq!(block.copies, moved);
2613        close(
2614            block.own.mass_kg,
2615            3.0 * one.own.mass_kg,
2616            1e-15,
2617            "three blocks",
2618        );
2619        assert!(block.own.cg_m.truncate().length() < 1e-17);
2620        let (_, tube) = single.find("mmt").unwrap();
2621        let extra = 2.0 * (tube.own.mass_kg + one.own.mass_kg);
2622        close(
2623            clustered.structure.mass_kg,
2624            single.structure.mass_kg + extra,
2625            1e-14,
2626            "structure",
2627        );
2628        let (_, mount) = clustered.find("mmt").unwrap();
2629        assert_eq!(mount.contents_copies().unwrap(), moved);
2630        let (_, ring) = clustered.find("ring-fore").unwrap();
2631        let Part::CenteringRing(ring) = &ring.part else {
2632            panic!("a ring");
2633        };
2634        assert_eq!(ring.inner_radius_m, 0.02);
2635        let findings = crate::checks::check(&clustered_design).unwrap();
2636        for id in ["ring-fore", "ring-aft"] {
2637            assert!(
2638                findings.contains(&crate::Finding::RingOverlapsInnerTube {
2639                    ring: id.to_owned(),
2640                    tube: "mmt".to_owned(),
2641                }),
2642                "{findings:?}"
2643            );
2644        }
2645        // The tubes reach 0.04 m from the axis, past the airframe's 0.0255 m bore.
2646        assert!(
2647            findings.iter().any(|f| matches!(
2648                f,
2649                crate::Finding::InternalPartWiderThanParent { component, reach_m, .. }
2650                    if component == "mmt" && (reach_m - 0.04).abs() < 1e-15
2651            )),
2652            "{findings:?}"
2653        );
2654        assert!(
2655            !crate::checks::check(&with_block(Vec::new()))
2656                .unwrap()
2657                .iter()
2658                .any(|f| matches!(f, crate::Finding::RingOverlapsInnerTube { .. }))
2659        );
2660    }
2661
2662    /// The tests' rocket with a pod set of `count` pods `d = 0.05` m from the axis, the first at
2663    /// `angle_rad`, 0.1 m aft of the airframe's forward end: each pod a 0.3 m cardboard tube
2664    /// (radius 12 mm, wall 1 mm) holding a 50 g mass 0.1 m long, radius 8 mm, 0.02 m from its top.
2665    fn podded(count: u32, angle_rad: f64) -> Rocket {
2666        let mut design = three_fin_rocket();
2667        let mut pod_tube = body("pod-tube", tube(0.3, 0.012, 0.001));
2668        pod_tube.children = vec![attached(
2669            "pod-mass",
2670            mass_component(0.05, 0.1, 0.008),
2671            top(0.02),
2672        )];
2673        let mut pods = attached(
2674            "pods",
2675            Part::PodSet(PodSet {
2676                count,
2677                radial_offset_m: 0.05,
2678                angle_rad,
2679            }),
2680            top(0.1),
2681        );
2682        pods.children = vec![pod_tube];
2683        design.stages[0].components[1].children.push(pods);
2684        design
2685    }
2686
2687    /// [`podded`]'s pods as a parallel stage of their own, hung on the same tube at the same place.
2688    fn boosted(count: u32, angle_rad: f64) -> Rocket {
2689        let mut design = podded(count, angle_rad);
2690        let pods = design.stages[0].components[1]
2691            .children
2692            .pop()
2693            .expect("podded hangs its pods last");
2694        let Part::PodSet(set) = pods.part else {
2695            unreachable!("podded's last child is its pod set")
2696        };
2697        let mut boosters = stage("pods", pods.children);
2698        boosters.parallel = Some(ParallelStage {
2699            on: "airframe".to_owned(),
2700            position: pods.position.expect("an attached part has a position"),
2701            pods: set,
2702        });
2703        design.stages.push(boosters);
2704        design
2705    }
2706
2707    /// A parallel stage lays out as the same pods hung on its tube as a pod set: every part in the
2708    /// same place with the same copies and mass, bit for bit, and the structure the same. Only
2709    /// whose they are differs: the pods' parts are the parallel stage's, their mass is that stage's
2710    /// and not the tube's, and the stage spans the pods and hangs on stage 0.
2711    #[test]
2712    fn a_parallel_stage_lays_out_as_a_pod_set_of_its_own_stage() {
2713        for (count, angle_rad) in [(2, 0.0), (1, 0.3), (3, 1.0)] {
2714            let pods = podded(count, angle_rad).layout().unwrap();
2715            let boosters = boosted(count, angle_rad).layout().unwrap();
2716            assert_eq!(boosters.components.len(), pods.components.len());
2717            let own_stage = ["pods", "pod-tube", "pod-mass"];
2718            for (got, want) in boosters.components.iter().zip(&pods.components) {
2719                assert_eq!(got.id, want.id, "depth-first order kept");
2720                let theirs = own_stage.contains(&got.id.as_str());
2721                assert_eq!(got.stage, usize::from(theirs), "{}'s stage", got.id);
2722                let mut same = got.clone();
2723                same.stage = want.stage;
2724                if got.id == "airframe" {
2725                    // The tube no longer carries the pods: its mass with children is the podded
2726                    // tube's less theirs.
2727                    let (_, set) = pods.find("pods").unwrap();
2728                    close(
2729                        got.with_children.mass_kg,
2730                        want.with_children.mass_kg - set.with_children.mass_kg,
2731                        1e-15,
2732                        "the tube without its pods",
2733                    );
2734                    same.with_children = want.with_children;
2735                }
2736                assert_eq!(&same, want, "{}", got.id);
2737            }
2738            let (_, set) = pods.find("pods").unwrap();
2739            assert_eq!(boosters.stages.len(), 2);
2740            let stage = &boosters.stages[1];
2741            assert_eq!(stage.id, "pods");
2742            assert_eq!(stage.hung_on, Some(0));
2743            assert_eq!(boosters.stages[0].hung_on, None);
2744            assert_eq!(stage.mass, set.with_children);
2745            assert_eq!(
2746                (stage.fore_station_m, stage.aft_station_m),
2747                (set.fore_station_m, set.aft_station_m())
2748            );
2749            assert_eq!(boosters.length_m, pods.length_m, "the pods don't stack");
2750            close(
2751                boosters.structure.mass_kg,
2752                pods.structure.mass_kg,
2753                1e-15,
2754                "structure mass",
2755            );
2756            close(
2757                (boosters.structure.cg_m - pods.structure.cg_m).length(),
2758                0.0,
2759                1e-15,
2760                "structure center",
2761            );
2762            let inertia = boosters.structure.inertia_kg_m2 - pods.structure.inertia_kg_m2;
2763            for column in 0..3 {
2764                close(
2765                    inertia.col(column).length(),
2766                    0.0,
2767                    1e-15,
2768                    "structure inertia",
2769                );
2770            }
2771        }
2772
2773        // The design page's worked example: the two pods, each a tube and 50 g, are the stage's.
2774        let boosters = boosted(2, 0.0);
2775        let tube_kg = 790.0 * std::f64::consts::PI * (0.012_f64.powi(2) - 0.011_f64.powi(2)) * 0.3;
2776        close(
2777            boosters.layout().unwrap().stages[1].mass.mass_kg,
2778            2.0 * (tube_kg + 0.05),
2779            1e-15,
2780            "the stage's mass",
2781        );
2782        // The design page's JSON is that stage, empty but for each copy's tube.
2783        let page = include_str!("../../../docs/physics/design.md");
2784        let block = page
2785            .split("\n## Parallel stages\n")
2786            .nth(1)
2787            .and_then(|stages| stages.split("```json\n").nth(1))
2788            .and_then(|json| json.split("```").next())
2789            .expect("the Parallel stages section has a JSON block");
2790        let mut written = boosters.stages[1].clone();
2791        written.components[0].children.clear();
2792        assert_eq!(
2793            serde_json::from_str::<Stage>(block).unwrap(),
2794            written,
2795            "{block}"
2796        );
2797    }
2798
2799    /// A parallel stage hangs on a body tube of an axial stage before it, measured along that
2800    /// tube; anything else is refused by name.
2801    #[test]
2802    fn a_parallel_stage_hangs_on_a_body_tube_of_an_earlier_axial_stage() {
2803        let refused = |design: &Rocket, needle: &str| match design.layout() {
2804            Err(DesignError::Tree { id, message }) => {
2805                assert_eq!(id, "pods");
2806                assert!(message.contains(needle), "{message}");
2807            }
2808            other => panic!("expected a tree error naming `{needle}`, got {other:?}"),
2809        };
2810        let hangs = "hangs on a body tube of an axial stage before it";
2811        let on = |on: &str| {
2812            let mut design = boosted(2, 0.0);
2813            design.stages[1].parallel.as_mut().unwrap().on = on.to_owned();
2814            design
2815        };
2816        refused(&on("nose"), hangs);
2817        refused(&on("fins"), hangs);
2818        refused(&on("nowhere"), hangs);
2819        // On its own pod's tube, or listed before the stage it hangs on.
2820        refused(&on("pod-tube"), hangs);
2821        let mut first = boosted(2, 0.0);
2822        first.stages.swap(0, 1);
2823        refused(&first, hangs);
2824        // On a tube of another parallel stage.
2825        let mut nested = boosted(2, 0.0);
2826        let mut more = nested.stages[1].clone();
2827        more.id = "more".to_owned();
2828        more.components[0].id = "more-tube".to_owned();
2829        more.components[0].children[0].id = "more-mass".to_owned();
2830        more.parallel.as_mut().unwrap().on = "pod-tube".to_owned();
2831        nested.stages.push(more);
2832        match nested.layout() {
2833            Err(DesignError::Tree { id, message }) => {
2834                assert_eq!(id, "more");
2835                assert!(message.contains(hangs), "{message}");
2836            }
2837            other => panic!("expected a tree error, got {other:?}"),
2838        }
2839        // After a sibling: a parallel stage has none.
2840        let mut after = boosted(2, 0.0);
2841        after.stages[1].parallel.as_mut().unwrap().position = Position::After { aft_offset_m: 0.0 };
2842        refused(&after, "not after a sibling");
2843        // Under a mass override covering what its tube or the tube's stage holds, which doesn't
2844        // say whether it covers the parallel stage; the tube's own override does say.
2845        let covered = "under a mass override that covers what the tube or its stage holds";
2846        let heavy = Overrides {
2847            mass_kg: Some(1.0),
2848            ..Overrides::default()
2849        };
2850        let mut staged = boosted(2, 0.0);
2851        staged.stages[0].overrides = heavy;
2852        refused(&staged, covered);
2853        let mut tube = boosted(2, 0.0);
2854        tube.stages[0].components[1].overrides = heavy;
2855        tube.layout().unwrap();
2856        tube.stages[0].components[1].overrides_include_children = true;
2857        refused(&tube, covered);
2858        // Its pods are checked as a pod set's: a part that isn't a body component in the stack.
2859        let mut loose = boosted(2, 0.0);
2860        loose.stages[1].components[0] = attached("pod-tube", fins(0.1, 0.06), top(0.0));
2861        assert!(loose.layout().is_err());
2862        // An id the airframe already uses.
2863        let mut twice = boosted(2, 0.0);
2864        twice.stages[1].id = "airframe".to_owned();
2865        assert!(matches!(twice.layout(), Err(DesignError::DuplicateId(_))));
2866    }
2867
2868    /// A pod's mass properties are the parallel-axis sum worked by hand: the tube and the mass as
2869    /// textbook cylinders, stacked along the pod, then moved out to each pod's axis.
2870    #[test]
2871    fn a_pod_is_its_stack_repeated_with_its_parallel_axis_term() {
2872        use std::f64::consts::{FRAC_PI_2, PI};
2873        // One pod, about its own axis. The pod set sits at station 0.2 + 0.1 = 0.3 m.
2874        let (rho, length, ro, ri) = (790.0, 0.3, 0.012, 0.011);
2875        let m_t = rho * PI * (ro * ro - ri * ri) * length;
2876        let (ax_t, tr_t) = (
2877            m_t * (ro * ro + ri * ri) / 2.0,
2878            m_t * (3.0 * (ro * ro + ri * ri) + length * length) / 12.0,
2879        );
2880        let z_t = 0.3 + 0.15;
2881        let (m_c, l_c, r_c) = (0.05, 0.1, 0.008);
2882        let (ax_c, tr_c) = (
2883            m_c * r_c * r_c / 2.0,
2884            m_c * (3.0 * r_c * r_c + l_c * l_c) / 12.0,
2885        );
2886        let z_c = 0.3 + 0.02 + 0.05;
2887        let m1 = m_t + m_c;
2888        let z1 = (m_t * z_t + m_c * z_c) / m1;
2889        let axial1 = ax_t + ax_c;
2890        let transverse1 = tr_t + m_t * (z_t - z1).powi(2) + tr_c + m_c * (z_c - z1).powi(2);
2891        let d = 0.05;
2892
2893        // Two pods on the x axis, at 0 and π.
2894        let base = three_fin_rocket().layout().unwrap();
2895        let two = podded(2, 0.0).layout().unwrap();
2896        let (_, pods) = two.find("pods").unwrap();
2897        let mass = pods.with_children;
2898        close(mass.mass_kg, 2.0 * m1, 1e-15, "two pods' mass");
2899        close(mass.cg_m.x, 0.0, 1e-16, "center x");
2900        close(mass.cg_m.y, 0.0, 1e-16, "center y");
2901        close(-mass.cg_m.z, z1, 1e-15, "center station");
2902        let i = mass.inertia_kg_m2;
2903        // Both pods lie on the x axis, so rolling about x moves them only along the pod's length.
2904        close(i.col(0).x, 2.0 * transverse1, 1e-15, "I_xx");
2905        close(i.col(1).y, 2.0 * (transverse1 + m1 * d * d), 1e-15, "I_yy");
2906        close(i.col(2).z, 2.0 * (axial1 + m1 * d * d), 1e-15, "I_zz");
2907        for (k, product) in [i.col(1).x, i.col(2).x, i.col(2).y].into_iter().enumerate() {
2908            close(product, 0.0, 1e-16, &format!("product {k}"));
2909        }
2910        close(pods.length_m, 0.3, 1e-15, "the pod's extent");
2911        assert_eq!(pods.own.mass_kg, 0.0);
2912        let (_, pod_tube) = two.find("pod-tube").unwrap();
2913        close(pod_tube.fore_station_m, 0.3, 1e-15, "pod tube station");
2914        assert_eq!(pod_tube.copies.len(), 2);
2915        close(pod_tube.copies[1].offset_m[0], -d, 1e-16, "second pod x");
2916        assert_eq!(pod_tube.copies[1].roll_rad, PI);
2917        close(
2918            two.structure.mass_kg,
2919            base.structure.mass_kg + 2.0 * m1,
2920            1e-14,
2921            "structure",
2922        );
2923
2924        // One pod at 90°: off the axis, so its product of inertia about the body origin is
2925        // `I_yz = −m y z` with `y = d`, `z = −z1`.
2926        let one = podded(1, FRAC_PI_2).layout().unwrap();
2927        let (_, pod) = one.find("pods").unwrap();
2928        let mass = pod.with_children;
2929        close(mass.mass_kg, m1, 1e-15, "one pod's mass");
2930        close(mass.cg_m.x, 0.0, 1e-16, "center x");
2931        close(mass.cg_m.y, d, 1e-16, "center y");
2932        let i = mass.inertia_kg_m2;
2933        close(i.col(0).x, transverse1, 1e-15, "I_xx about its center");
2934        close(i.col(2).z, axial1, 1e-15, "I_zz about its center");
2935        let about_origin = mass.inertia_about(DVec3::ZERO);
2936        close(about_origin.col(2).y, m1 * d * z1, 1e-15, "I_yz");
2937        close(
2938            about_origin.col(2).z,
2939            axial1 + m1 * d * d,
2940            1e-15,
2941            "I_zz about the axis",
2942        );
2943        close(
2944            one.structure.cg_m.y,
2945            (base.structure.mass_kg * base.structure.cg_m.y + m1 * d)
2946                / (base.structure.mass_kg + m1),
2947            1e-15,
2948            "the rocket's center across the axis",
2949        );
2950    }
2951
2952    /// What a pod holds turns with its pod, as a rotational pattern: a mass 3 mm off the pod's
2953    /// axis, outward on the pod at 0°, is outward on the pod at 180° too, so the pair's center
2954    /// stays on the axis; alone at 90° it sits at `y = d + e`. A center override inside a pod is measured
2955    /// in the pod as written, and turns with it. An override on the pod set, covering its pods, is
2956    /// all the pods' mass; one on a part inside a pod is each copy's.
2957    #[test]
2958    fn what_a_pod_holds_turns_with_it_and_overrides_keep_their_scope() {
2959        use std::f64::consts::{FRAC_PI_2, PI};
2960        let (d, e) = (0.05, 0.003);
2961        let off_axis = |count: u32, angle_rad: f64| {
2962            let mut design = podded(count, angle_rad);
2963            let pod_tube = &mut design.stages[0].components[1].children[6].children[0];
2964            if let Part::MassComponent(mass) = &mut pod_tube.children[0].part {
2965                mass.packing.radial_offset_m = e;
2966            }
2967            design
2968        };
2969        let (rho, length, ro, ri) = (790.0, 0.3, 0.012, 0.011);
2970        let m_t = rho * PI * (ro * ro - ri * ri) * length;
2971        let m_c = 0.05;
2972
2973        let two = off_axis(2, 0.0).layout().unwrap();
2974        let pods = two.find("pods").unwrap().1.with_children;
2975        close(pods.cg_m.x, 0.0, 1e-16, "the pair's center x");
2976        close(pods.cg_m.y, 0.0, 1e-16, "the pair's center y");
2977        let roll = pods
2978            .inertia_about(DVec3::new(0.0, 0.0, pods.cg_m.z))
2979            .col(2)
2980            .z;
2981        let hand = 2.0
2982            * (m_t * (ro * ro + ri * ri) / 2.0
2983                + m_t * d * d
2984                + m_c * 0.008 * 0.008 / 2.0
2985                + m_c * (d + e) * (d + e));
2986        close(roll, hand, 1e-15, "the pair's roll inertia");
2987
2988        let one = off_axis(1, FRAC_PI_2).layout().unwrap();
2989        let (_, mass) = one.find("pod-mass").unwrap();
2990        close(mass.own.cg_m.x, 0.0, 1e-16, "the turned mass's x");
2991        close(mass.own.cg_m.y, d + e, 1e-16, "the turned mass's y");
2992
2993        // The pod tube's center set 10 mm out from the pod's axis, as written.
2994        let mut design = podded(1, FRAC_PI_2);
2995        let pod_tube = &mut design.stages[0].components[1].children[6].children[0];
2996        pod_tube.overrides.cg_xy_m = Some([0.01, 0.0]);
2997        let layout = design.layout().unwrap();
2998        let (_, tube) = layout.find("pod-tube").unwrap();
2999        close(tube.own.cg_m.x, 0.0, 1e-16, "the overridden center's x");
3000        close(
3001            tube.own.cg_m.y,
3002            d + 0.01,
3003            1e-16,
3004            "the overridden center's y",
3005        );
3006
3007        let set_to = |mass_kg: f64, on_set: bool| {
3008            let mut design = podded(2, 0.0);
3009            let pods = &mut design.stages[0].components[1].children[6];
3010            if on_set {
3011                pods.overrides.mass_kg = Some(mass_kg);
3012                pods.overrides_include_children = true;
3013            } else {
3014                pods.children[0].children[0].overrides.mass_kg = Some(mass_kg);
3015            }
3016            let layout = design.layout().unwrap();
3017            layout.find("pods").unwrap().1.with_children.mass_kg
3018        };
3019        close(set_to(0.3, true), 0.3, 1e-15, "an override on the pod set");
3020        close(
3021            set_to(0.1, false),
3022            2.0 * (m_t + 0.1),
3023            1e-15,
3024            "an override in each pod",
3025        );
3026    }
3027
3028    /// A pod's nose cone is not the airframe's: the nose-base reference diameter doesn't take it.
3029    #[test]
3030    fn a_pod_s_nose_is_not_the_reference_nose() {
3031        let mut design = podded(2, 0.0);
3032        let pods = &mut design.stages[0].components[1].children[6];
3033        pods.children.insert(0, body("pod-nose", nose(0.06, 0.012)));
3034        design.stages[0].components.remove(0);
3035        design.reference_diameter = ReferenceDiameter::NoseBase {};
3036        let error = design.layout().unwrap_err().to_string();
3037        assert!(error.contains("there is no nose cone"), "{error}");
3038    }
3039
3040    /// A pod's body components stack from the pod set and take their automatic radii from one
3041    /// another; a motor in a pod is one motor per pod; a pod's tubes are not internal parts to
3042    /// the checks; and the wrong trees are refused.
3043    #[test]
3044    fn pods_stack_hold_motors_and_refuse_the_wrong_trees() {
3045        let mut design = podded(3, 0.0);
3046        let pods = &mut design.stages[0].components[1].children[6];
3047        // The pods' aft ends 0.2 m past the airframe's, at 1.2 m: 1.0 − 0.36 + 0.2 = 0.84 m.
3048        pods.position = Some(bottom(0.2));
3049        let mut pod_nose = body("pod-nose", nose(0.06, 0.0));
3050        pod_nose.auto = vec![AutoDimension::BaseRadius];
3051        pods.children.insert(0, pod_nose);
3052        pods.children[1].motor_mount = Some(MotorMount { overhang_m: 0.005 });
3053        design.configurations.push(Configuration {
3054            id: "pods".to_owned(),
3055            name: String::new(),
3056            motors: vec![crate::testing::motor("pod-tube", 0.018, 0.1)],
3057        });
3058        let layout = design.layout().unwrap();
3059        let (_, set) = layout.find("pods").unwrap();
3060        close(set.length_m, 0.36, 1e-15, "the pods' extent");
3061        let (_, cone) = layout.find("pod-nose").unwrap();
3062        let Part::NoseCone(cone_part) = &cone.part else {
3063            panic!("a nose cone");
3064        };
3065        assert_eq!(cone_part.base_radius_m, 0.012);
3066        close(cone.fore_station_m, 0.84, 1e-15, "pod nose station");
3067        close(station(&layout, "pod-tube"), 0.9, 1e-15, "pod tube station");
3068        let three = PodSet {
3069            count: 3,
3070            radial_offset_m: 0.05,
3071            angle_rad: 0.0,
3072        }
3073        .pods()
3074        .unwrap();
3075        assert_eq!(cone.copies, three);
3076        assert_eq!(layout.find("pod-mass").unwrap().1.copies, three);
3077
3078        let assembly = design.assemble("pods").unwrap();
3079        assert_eq!(assembly.motors.len(), 3);
3080        for (motor, pod) in assembly.motors.iter().zip(&three) {
3081            assert_eq!([motor.nozzle_m.x, motor.nozzle_m.y], pod.offset_m);
3082            close(motor.nozzle_station_m(), 1.2 + 0.005, 1e-15, "nozzle");
3083        }
3084
3085        // The pods run past the airframe's end, which the checks allow a pod.
3086        let findings = crate::checks::check(&design).unwrap();
3087        assert!(
3088            !findings.iter().any(|f| format!("{f:?}").contains("pod")),
3089            "{findings:?}"
3090        );
3091
3092        // The design page's JSON is the tests' pod set, empty but for its tube.
3093        let page = include_str!("../../../docs/physics/design.md");
3094        let block = page
3095            .split("\n## Pods\n")
3096            .nth(1)
3097            .and_then(|pods| pods.split("```json\n").nth(1))
3098            .and_then(|json| json.split("```").next())
3099            .expect("the Pods section has a JSON block");
3100        let mut written = podded(2, 0.0).stages[0].components[1].children[6].clone();
3101        written.children[0].children.clear();
3102        assert_eq!(
3103            serde_json::from_str::<Component>(block).unwrap(),
3104            written,
3105            "{block}"
3106        );
3107
3108        let json = serde_json::to_string(&design).unwrap();
3109        assert!(json.contains("\"pod_set\""));
3110        assert_eq!(serde_json::from_str::<Rocket>(&json).unwrap(), design);
3111
3112        let refused = |edit: &dyn Fn(&mut Component), want: &str| {
3113            let mut design = podded(2, 0.0);
3114            edit(&mut design.stages[0].components[1].children[6]);
3115            let error = design.layout().unwrap_err().to_string();
3116            assert!(error.contains(want), "{error}");
3117        };
3118        refused(
3119            &|pods| {
3120                pods.children
3121                    .push(attached("fin", fins(0.1, 0.05), top(0.0)))
3122            },
3123            "a pod holds body components",
3124        );
3125        refused(
3126            &|pods| pods.children[0].position = Some(top(0.0)),
3127            "stacks and takes no position",
3128        );
3129        refused(
3130            &|pods| {
3131                if let Part::PodSet(set) = &mut pods.part {
3132                    set.count = 0;
3133                }
3134            },
3135            "pod count",
3136        );
3137        refused(
3138            &|pods| {
3139                if let Part::PodSet(set) = &mut pods.part {
3140                    set.radial_offset_m = f64::NAN;
3141                }
3142            },
3143            "pod radial offset",
3144        );
3145        refused(
3146            &|pods| {
3147                if let Part::PodSet(set) = &mut pods.part {
3148                    set.count = PodSet::MAX_COUNT + 1;
3149                }
3150            },
3151            "pod count (1 to 64)",
3152        );
3153        refused(
3154            &|pods| pods.overrides.mass_kg = Some(0.2),
3155            "an override on it must cover its pods",
3156        );
3157        refused(
3158            &|pods| {
3159                let mut inner = pods.clone();
3160                inner.id = "inner-pods".to_owned();
3161                inner.children[0].id = "inner-tube".to_owned();
3162                inner.children[0].children[0].id = "inner-mass".to_owned();
3163                pods.children[0].children.push(inner);
3164            },
3165            "a pod set can't hang from a pod",
3166        );
3167        let mut on_nose = podded(2, 0.0);
3168        let pods = on_nose.stages[0].components[1].children.pop().unwrap();
3169        on_nose.stages[0].components[0].children.push(pods);
3170        let error = on_nose.layout().unwrap_err().to_string();
3171        assert!(error.contains("attaches to a body tube"), "{error}");
3172    }
3173
3174    /// A pod of no length (OpenRocket's "phantom body": a tube of no length, radius or wall) weighs
3175    /// nothing and holds what hangs from it at the pod's axis. A launch lug on it, turned to π, is
3176    /// the textbook hollow cylinder with its axis its own radius inward of the pod's, `d − R`, on
3177    /// both pods, since it turns with its pod; the pair's inertia is worked by hand. An empty pod
3178    /// set lays out and weighs nothing (M1.13b2).
3179    #[test]
3180    fn a_pod_of_no_length_holds_its_parts_at_the_pod_s_axis() {
3181        use std::f64::consts::PI;
3182        let (rho, length, ro, ri, d) = (790.0, 0.05, 0.004, 0.0035, 0.05);
3183        let lug = LaunchLug {
3184            length_m: length,
3185            outer_radius_m: ro,
3186            thickness_m: ro - ri,
3187            angle_rad: PI,
3188            count: 1,
3189            spacing_m: 0.0,
3190            material: crate::testing::cardboard(),
3191        };
3192        let mut phantom = body("phantom", tube(0.0, 0.0, 0.0));
3193        phantom.children = vec![attached(
3194            "pod-lug",
3195            Part::LaunchLug(lug),
3196            Position::Middle { aft_offset_m: 0.0 },
3197        )];
3198        let mut pods = attached(
3199            "pods",
3200            Part::PodSet(PodSet {
3201                count: 2,
3202                radial_offset_m: d,
3203                angle_rad: 0.0,
3204            }),
3205            top(0.1),
3206        );
3207        pods.children = vec![phantom];
3208        let mut design = three_fin_rocket();
3209        design.stages[0].components[1].children.push(pods);
3210        let base = three_fin_rocket().layout().unwrap();
3211        let layout = design.layout().unwrap();
3212
3213        let (_, set) = layout.find("pods").unwrap();
3214        assert_eq!(set.length_m, 0.0);
3215        let (_, phantom) = layout.find("phantom").unwrap();
3216        assert_eq!(phantom.own.mass_kg, 0.0);
3217        close(phantom.fore_station_m, 0.3, 1e-15, "the phantom's station");
3218        // Centered on a tube of no length at 0.3 m: from 0.275 m to 0.325 m.
3219        close(
3220            station(&layout, "pod-lug"),
3221            0.3 - 0.5 * length,
3222            1e-15,
3223            "lug station",
3224        );
3225        let m = rho * PI * (ro * ro - ri * ri) * length;
3226        let axial = m * (ro * ro + ri * ri) / 2.0;
3227        let transverse = m * (3.0 * (ro * ro + ri * ri) + length * length) / 12.0;
3228        let r = d - ro;
3229        let mass = set.with_children;
3230        close(mass.mass_kg, 2.0 * m, 1e-18, "two lugs' mass");
3231        close(mass.cg_m.x, 0.0, 1e-16, "center x");
3232        close(mass.cg_m.y, 0.0, 1e-16, "center y");
3233        close(-mass.cg_m.z, 0.3, 1e-15, "center station");
3234        let i = mass.inertia_kg_m2;
3235        close(i.col(0).x, 2.0 * transverse, 1e-18, "I_xx");
3236        close(i.col(1).y, 2.0 * (transverse + m * r * r), 1e-18, "I_yy");
3237        close(i.col(2).z, 2.0 * (axial + m * r * r), 1e-18, "I_zz");
3238        // Alone, the first pod's lug sits at `x = d − R`.
3239        let (_, lug) = layout.find("pod-lug").unwrap();
3240        close(lug.own.cg_m.x, 0.0, 1e-16, "the pair's center");
3241        let Part::LaunchLug(one) = &lug.part else {
3242            unreachable!("the lug")
3243        };
3244        let one = MassProperties::placed(one.mass_properties(0.0).unwrap(), &lug.copies[..1]);
3245        close(one.cg_m.x, r, 1e-16, "the first lug across the axis");
3246
3247        let mut empty = design.clone();
3248        let pods = empty.stages[0].components[1].children.last_mut();
3249        pods.expect("the pod set").children.clear();
3250        let layout = empty.layout().unwrap();
3251        let (_, set) = layout.find("pods").unwrap();
3252        assert!(set.length_m.is_sign_positive());
3253        assert_eq!(set.with_children.mass_kg, 0.0);
3254        assert_eq!(set.with_children.inertia_kg_m2, DMat3::ZERO);
3255        assert_eq!(layout.structure, base.structure);
3256
3257        // Only a pod may hold a body component of no length: a stage refuses one.
3258        let mut flat = three_fin_rocket();
3259        flat.stages[0]
3260            .components
3261            .push(body("flat", tube(0.0, 0.0, 0.0)));
3262        let error = flat.layout().unwrap_err();
3263        assert!(
3264            matches!(
3265                error,
3266                DesignError::InComponent { ref id, ref source }
3267                    if id == "flat" && matches!(**source, DesignError::Domain {
3268                        what: "body component length",
3269                        ..
3270                    })
3271            ),
3272            "{error:?}"
3273        );
3274    }
3275
3276    /// An override on a part inside a cluster is each copy's: set to the part's own mass it changes
3277    /// nothing, and doubled it doubles every copy. Both a solid part (an engine block) and a packed
3278    /// one (a mass component, rebuilt as its packing's cylinder) are worked one tube at a time.
3279    #[test]
3280    fn an_override_inside_a_cluster_is_each_copy_s() {
3281        let three: Vec<[f64; 2]> = [90.0_f64, 210.0, 330.0]
3282            .iter()
3283            .map(|a| [0.02 * a.to_radians().cos(), 0.02 * a.to_radians().sin()])
3284            .collect();
3285        let with = |part: Part, mass_kg: Option<f64>| {
3286            let mut design = crate::testing::three_fin_rocket();
3287            let mount = &mut design.stages[0].components[1].children[0];
3288            if let Part::InnerTube(tube) = &mut mount.part {
3289                tube.cluster_m = three.clone();
3290            }
3291            let mut inside = attached("inside", part, top(0.0));
3292            inside.overrides.mass_kg = mass_kg;
3293            mount.children = vec![inside];
3294            design.layout().unwrap()
3295        };
3296        for part in [
3297            inner_tube(0.01, 0.019, 0.005),
3298            mass_component(0.05, 0.04, 0.015),
3299        ] {
3300            let free = with(part.clone(), None);
3301            let (_, one) = free.find("inside").unwrap();
3302            let each_kg = one.own.mass_kg / 3.0;
3303            let same = with(part.clone(), Some(each_kg));
3304            let (a, b) = (&free.structure, &same.structure);
3305            close(b.mass_kg, a.mass_kg, 1e-15, "structure mass");
3306            assert!(
3307                (b.cg_m - a.cg_m).length() < 1e-15,
3308                "{:?} vs {:?}",
3309                b.cg_m,
3310                a.cg_m
3311            );
3312            for (x, y) in [
3313                (a.inertia_kg_m2.x_axis, b.inertia_kg_m2.x_axis),
3314                (a.inertia_kg_m2.y_axis, b.inertia_kg_m2.y_axis),
3315                (a.inertia_kg_m2.z_axis, b.inertia_kg_m2.z_axis),
3316            ] {
3317                assert!((x - y).length() < 1e-15, "{x:?} vs {y:?}");
3318            }
3319            let doubled = with(part, Some(2.0 * each_kg));
3320            let (_, inside) = doubled.find("inside").unwrap();
3321            close(inside.own.mass_kg, 6.0 * each_kg, 1e-15, "doubled");
3322            assert!(inside.own.cg_m.truncate().length() < 1e-17);
3323        }
3324    }
3325
3326    /// The tree's structure equals the parts placed by hand at their stations and combined.
3327    #[test]
3328    fn tree_structure_matches_parts_placed_by_hand() {
3329        let design = three_fin_rocket();
3330        let layout = design.layout().unwrap();
3331        let bore = 0.027 - 0.0015;
3332        let placed = |part: Part, body_radius: Option<f64>, s: f64| {
3333            part.mass_properties(body_radius)
3334                .unwrap()
3335                .translated(DVec3::new(0.0, 0.0, -s))
3336        };
3337        let mut chute = design.stages[0].components[1].children[4].part.clone();
3338        if let Part::Parachute(p) = &mut chute {
3339            p.packing.radius_m = bore;
3340        }
3341        let parts = [
3342            placed(nose(0.2, 0.027), None, 0.0),
3343            placed(tube(0.8, 0.027, 0.0015), None, 0.2),
3344            placed(inner_tube(0.3, 0.020, 0.001), None, 0.7),
3345            placed(ring(0.006, bore, 0.020), None, 1.0 - 0.006 - 0.25),
3346            placed(ring(0.006, bore, 0.020), None, 1.0 - 0.006 - 0.02),
3347            placed(fins(0.1, 0.06), Some(0.027), 0.9),
3348            placed(chute, None, 0.25),
3349            placed(
3350                design.stages[0].components[1].children[5].part.clone(),
3351                Some(0.027),
3352                0.2 + 0.5 * (0.8 - 0.05),
3353            ),
3354        ];
3355        let want = MassProperties::combine(&parts);
3356        let got = layout.structure;
3357        close(got.mass_kg, want.mass_kg, 1e-15 * want.mass_kg, "mass");
3358        assert!(
3359            (got.cg_m - want.cg_m).length() < 1e-14,
3360            "{:?} vs {:?}",
3361            got.cg_m,
3362            want.cg_m
3363        );
3364        let scale = want
3365            .inertia_kg_m2
3366            .to_cols_array()
3367            .iter()
3368            .fold(0.0f64, |m, v| m.max(v.abs()));
3369        let diff = (got.inertia_kg_m2 - want.inertia_kg_m2)
3370            .to_cols_array()
3371            .iter()
3372            .fold(0.0f64, |m, v| m.max(v.abs()));
3373        assert!(diff < 1e-13 * scale, "{diff:e}");
3374        assert_eq!(layout.stages[0].mass, got);
3375    }
3376
3377    fn tensor_close(a: DMat3, b: DMat3, rel: f64) {
3378        let scale = b.to_cols_array().iter().fold(0.0f64, |m, v| m.max(v.abs()));
3379        let diff = (a - b)
3380            .to_cols_array()
3381            .iter()
3382            .fold(0.0f64, |m, v| m.max(v.abs()));
3383        assert!(diff <= rel * scale, "{a:?}\nvs\n{b:?}");
3384    }
3385
3386    /// Each override does what `Overrides` says, alone or over the children, nested overrides
3387    /// apply first, and a stage's overrides cover the stage.
3388    #[test]
3389    fn overrides_rescale_move_and_replace() {
3390        let bay = || attached("bay", mass_component(0.4, 0.1, 0.02), top(0.3));
3391        let mut airframe = body("airframe", tube(0.8, 0.03, 0.001));
3392        airframe.children = vec![bay()];
3393        let plain = rocket(vec![stage(
3394            "s",
3395            vec![body("nose", nose(0.2, 0.03)), airframe.clone()],
3396        )]);
3397        let base = plain.layout().unwrap();
3398        let (_, tube_plain) = base.find("airframe").unwrap();
3399        let (_, bay_plain) = base.find("bay").unwrap();
3400
3401        // Mass alone: the tube doubles, keeping its center, and its tensor doubles.
3402        let mut overridden = airframe.clone();
3403        overridden.overrides.mass_kg = Some(2.0 * tube_plain.own.mass_kg);
3404        let design = rocket(vec![stage(
3405            "s",
3406            vec![body("nose", nose(0.2, 0.03)), overridden.clone()],
3407        )]);
3408        let layout = design.layout().unwrap();
3409        let (_, t) = layout.find("airframe").unwrap();
3410        close(
3411            t.own.mass_kg,
3412            2.0 * tube_plain.own.mass_kg,
3413            1e-15,
3414            "scaled mass",
3415        );
3416        assert!((t.own.cg_m - tube_plain.own.cg_m).length() < 1e-15);
3417        tensor_close(
3418            t.own.inertia_kg_m2,
3419            tube_plain.own.inertia_kg_m2 * 2.0,
3420            1e-14,
3421        );
3422        let with = MassProperties::combine([&t.own, &bay_plain.with_children]);
3423        assert_eq!(t.with_children, with, "the child is still added");
3424
3425        // Center and inertia over the tube and its child together.
3426        overridden.overrides = Overrides {
3427            mass_kg: Some(1.5),
3428            cg_aft_m: Some(0.35),
3429            cg_xy_m: Some([0.001, -0.002]),
3430            inertia: Some(InertiaOverride::axisymmetric(0.001, 0.08)),
3431        };
3432        overridden.overrides_include_children = true;
3433        let design = rocket(vec![stage(
3434            "s",
3435            vec![body("nose", nose(0.2, 0.03)), overridden.clone()],
3436        )]);
3437        let layout = design.layout().unwrap();
3438        let (_, t) = layout.find("airframe").unwrap();
3439        assert_eq!(t.own, tube_plain.own, "own mass untouched");
3440        close(t.with_children.mass_kg, 1.5, 0.0, "mass");
3441        close(
3442            t.with_children.cg_m.z,
3443            -(0.2 + 0.35),
3444            1e-15,
3445            "center from the tube's forward end",
3446        );
3447        assert_eq!(
3448            (t.with_children.cg_m.x, t.with_children.cg_m.y),
3449            (0.001, -0.002)
3450        );
3451        assert_eq!(
3452            t.with_children.inertia_kg_m2,
3453            DMat3::from_diagonal(DVec3::new(0.08, 0.08, 0.001))
3454        );
3455
3456        // A stage override applies last, over everything, from the stage's forward end.
3457        let mut design = design;
3458        design.stages[0].overrides = Overrides {
3459            mass_kg: Some(3.0),
3460            cg_aft_m: Some(0.5),
3461            cg_xy_m: None,
3462            inertia: None,
3463        };
3464        let layout = design.layout().unwrap();
3465        let stage_mass = layout.stages[0].mass;
3466        close(stage_mass.mass_kg, 3.0, 0.0, "stage mass");
3467        close(stage_mass.cg_m.z, -0.5, 1e-15, "stage center");
3468        let bodies: Vec<MassProperties> = layout.body().map(|c| c.with_children).collect();
3469        let unscaled = MassProperties::combine(&bodies);
3470        // Without `cg_xy_m` the stage keeps its offset, which the airframe's override gave it.
3471        assert!(unscaled.cg_m.x.abs() > 1e-5);
3472        assert_eq!(
3473            (stage_mass.cg_m.x, stage_mass.cg_m.y),
3474            (unscaled.cg_m.x, unscaled.cg_m.y)
3475        );
3476        tensor_close(
3477            stage_mass.inertia_kg_m2,
3478            unscaled.inertia_kg_m2 * (3.0 / unscaled.mass_kg),
3479            1e-14,
3480        );
3481        assert_eq!(layout.structure, stage_mass);
3482
3483        // A massless body given a mass becomes a point mass at its center.
3484        let center = DVec3::new(0.0, 0.0, -0.4);
3485        let point = Overrides {
3486            mass_kg: Some(0.25),
3487            ..Overrides::default()
3488        }
3489        .apply(MassProperties::point(0.0, center), 0.0)
3490        .unwrap();
3491        assert_eq!(point, MassProperties::point(0.25, center));
3492
3493        // But a massless packed part takes it as a solid cylinder of its packing (ADR-063),
3494        // whether or not the override covers the parts inside.
3495        for covering in [false, true] {
3496            let mut airframe = airframe.clone();
3497            airframe.children = vec![attached(
3498                "ballast",
3499                mass_component(0.0, 0.1, 0.02),
3500                top(0.3),
3501            )];
3502            airframe.children[0].overrides.mass_kg = Some(0.25);
3503            airframe.children[0].overrides_include_children = covering;
3504            let design = rocket(vec![stage(
3505                "s",
3506                vec![body("nose", nose(0.2, 0.03)), airframe],
3507            )]);
3508            let layout = design.layout().unwrap();
3509            let (_, b) = layout.find("ballast").unwrap();
3510            let b = if covering { b.with_children } else { b.own };
3511            assert_eq!(b.mass_kg, 0.25);
3512            close(b.cg_m.z, -(0.2 + 0.3 + 0.05), 1e-15, "packing's center");
3513            let (r, l) = (0.02_f64, 0.1_f64);
3514            close(
3515                b.inertia_kg_m2.z_axis.z,
3516                0.5 * 0.25 * r * r,
3517                1e-15,
3518                "roll, m r²/2",
3519            );
3520            close(
3521                b.inertia_kg_m2.x_axis.x,
3522                0.25 * (3.0 * r * r + l * l) / 12.0,
3523                1e-15,
3524                "pitch, m (3r² + l²)/12",
3525            );
3526        }
3527
3528        // Overrides that make no real body are refused.
3529        let in_stage = |design: &Rocket| match design.layout() {
3530            Err(DesignError::InComponent { id, source }) if id == "s" => *source,
3531            other => panic!("{other:?}"),
3532        };
3533        let mut bad = plain.clone();
3534        bad.stages[0].overrides.mass_kg = Some(-1.0);
3535        assert!(matches!(in_stage(&bad), DesignError::Domain { .. }));
3536        let mut bad = plain.clone();
3537        bad.stages[0].overrides.inertia = Some(InertiaOverride::axisymmetric(1.0, 0.1));
3538        assert!(matches!(in_stage(&bad), DesignError::UnphysicalInertia(_)));
3539        for broken in [
3540            Overrides {
3541                cg_aft_m: Some(f64::NAN),
3542                ..Overrides::default()
3543            },
3544            Overrides {
3545                cg_xy_m: Some([0.0, f64::INFINITY]),
3546                ..Overrides::default()
3547            },
3548        ] {
3549            let mut bad = plain.clone();
3550            bad.stages[0].overrides = broken;
3551            assert!(matches!(in_stage(&bad), DesignError::Domain { .. }));
3552        }
3553        let mut bad = plain.clone();
3554        bad.stages[0].overrides.inertia = Some(InertiaOverride {
3555            xy_kg_m2: f64::NAN,
3556            ..InertiaOverride::axisymmetric(0.1, 1.0)
3557        });
3558        assert!(matches!(in_stage(&bad), DesignError::UnphysicalInertia(_)));
3559        // Zero mass scales the tensor to zero, but a massless body can't be given an inertia.
3560        let mut zero = plain.clone();
3561        zero.stages[0].overrides.mass_kg = Some(0.0);
3562        assert_eq!(zero.layout().unwrap().structure.inertia_kg_m2, DMat3::ZERO);
3563        let mut bad = plain;
3564        bad.stages[0].overrides.mass_kg = Some(0.0);
3565        bad.stages[0].overrides.inertia = Some(InertiaOverride::axisymmetric(1.0, 5.0));
3566        assert!(matches!(in_stage(&bad), DesignError::UnphysicalInertia(_)));
3567    }
3568
3569    /// A tree that doesn't hold together is refused with the offending id.
3570    #[test]
3571    fn malformed_trees_are_refused() {
3572        let base = || {
3573            rocket(vec![stage(
3574                "s",
3575                vec![
3576                    body("nose", nose(0.2, 0.03)),
3577                    body("tube", tube(0.5, 0.03, 0.001)),
3578                ],
3579            )])
3580        };
3581        let tree_error = |design: Rocket, want: &str| match design.layout() {
3582            Err(DesignError::Tree { id, .. }) => assert_eq!(id, want),
3583            other => panic!("{want}: {other:?}"),
3584        };
3585
3586        let mut d = base();
3587        d.stages[0].components[1].children =
3588            vec![attached("mmt", inner_tube(0.2, 0.01, 0.001), top(0.0))];
3589        d.stages[0].components[1].children[0].children =
3590            vec![attached("fins", fins(0.1, 0.05), top(0.0))];
3591        tree_error(d, "fins");
3592
3593        // A lug has no footing on a nose cone; a fin set may sit there with its root along the
3594        // surface (ADR-166), which a level trapezoid's is not.
3595        let mut d = base();
3596        let lug = Part::LaunchLug(crate::LaunchLug {
3597            length_m: 0.03,
3598            outer_radius_m: 0.003,
3599            thickness_m: 0.0005,
3600            angle_rad: 0.0,
3601            count: 1,
3602            spacing_m: 0.0,
3603            material: crate::testing::cardboard(),
3604        });
3605        d.stages[0].components[0].children = vec![attached("lug", lug, top(0.0))];
3606        tree_error(d, "lug");
3607        let mut d = base();
3608        d.stages[0].components[0].children = vec![attached("fins", fins(0.1, 0.05), top(0.0))];
3609        match d.layout() {
3610            Err(DesignError::InComponent { id, source }) => {
3611                assert_eq!(id, "fins");
3612                assert!(
3613                    matches!(&*source, DesignError::Geometry(m) if m.contains("stands")),
3614                    "{source}"
3615                );
3616            }
3617            other => panic!("fins on the nose: {other:?}"),
3618        }
3619
3620        let mut d = base();
3621        d.stages[0].components[1].children =
3622            vec![attached("inner-body", tube(0.1, 0.02, 0.001), top(0.0))];
3623        tree_error(d, "inner-body");
3624
3625        let mut d = base();
3626        d.stages[0]
3627            .components
3628            .push(body("ballast", mass_component(0.1, 0.1, 0.01)));
3629        tree_error(d, "ballast");
3630
3631        let mut d = base();
3632        d.stages[0].components[1].children = vec![body("unplaced", mass_component(0.1, 0.1, 0.01))];
3633        tree_error(d, "unplaced");
3634
3635        let mut d = base();
3636        d.stages[0].components[1].position = Some(top(0.0));
3637        tree_error(d, "tube");
3638
3639        let mut d = base();
3640        d.stages[0].components[1].auto = vec![AutoDimension::BaseRadius];
3641        tree_error(d, "tube");
3642
3643        let mut d = base();
3644        d.stages[0].components[1].children =
3645            vec![attached("ring", ring(0.005, 0.02, 0.0), top(0.0))];
3646        d.stages[0].components[1].children[0].motor_mount = Some(MotorMount::default());
3647        tree_error(d, "ring");
3648
3649        let mut d = base();
3650        d.stages[0].components[1].children =
3651            vec![attached("bay", mass_component(0.1, 0.1, 0.01), top(0.0))];
3652        d.stages[0].components[1].children[0].children =
3653            vec![attached("x", mass_component(0.1, 0.1, 0.01), top(0.0))];
3654        tree_error(d, "bay");
3655
3656        let mut d = base();
3657        d.stages[0].components[0].children =
3658            vec![attached("weight", mass_component(0.1, 0.05, 0.0), top(0.0))];
3659        d.stages[0].components[0].children[0].auto = vec![AutoDimension::PackedRadius];
3660        tree_error(d, "weight");
3661
3662        let mut d = base();
3663        d.stages.push(stage("empty", Vec::new()));
3664        tree_error(d, "empty");
3665
3666        let mut d = base();
3667        d.stages[0].components[1].id = "nose".to_owned();
3668        assert_eq!(d.layout(), Err(DesignError::DuplicateId("nose".to_owned())));
3669        let mut d = base();
3670        d.stages[0].components[1].id = String::new();
3671        assert!(matches!(d.layout(), Err(DesignError::DuplicateId(_))));
3672
3673        let mut d = base();
3674        d.stages.clear();
3675        assert!(matches!(d.layout(), Err(DesignError::Tree { .. })));
3676    }
3677
3678    /// A child's own override applies before its parent's override over the subtree: the parent's
3679    /// mass override rescales a total that already holds the child's overridden mass.
3680    #[test]
3681    fn nested_overrides_apply_deepest_first() {
3682        let mut airframe = body("airframe", tube(0.8, 0.03, 0.001));
3683        let mut bay = attached("bay", mass_component(0.4, 0.1, 0.02), top(0.3));
3684        bay.overrides.mass_kg = Some(1.0);
3685        airframe.children = vec![bay];
3686        let plain = rocket(vec![stage("s", vec![airframe.clone()])]);
3687        let layout = plain.layout().unwrap();
3688        let (_, t) = layout.find("airframe").unwrap();
3689        let (_, b) = layout.find("bay").unwrap();
3690        assert_eq!(b.own.mass_kg, 1.0);
3691        close(
3692            t.with_children.mass_kg,
3693            t.own.mass_kg + 1.0,
3694            1e-15,
3695            "child override inside",
3696        );
3697
3698        airframe.overrides.mass_kg = Some(3.0);
3699        airframe.overrides_include_children = true;
3700        let layout = rocket(vec![stage("s", vec![airframe])]).layout().unwrap();
3701        let (_, t2) = layout.find("airframe").unwrap();
3702        close(
3703            t2.with_children.mass_kg,
3704            3.0,
3705            0.0,
3706            "parent override over the total",
3707        );
3708        // The rescaled subtree keeps the center of the tube plus the 1 kg bay, not the 0.4 kg bay.
3709        close(
3710            t2.with_children.cg_m.z,
3711            t.with_children.cg_m.z,
3712            1e-15,
3713            "center kept",
3714        );
3715    }
3716
3717    /// The aft radius and aft shoulder take the tube behind a transition; missing shoulders, a
3718    /// shoulder with no tube, a nose-base reference with no nose, too deep a tree, and a bad part
3719    /// are all refused with the offending id.
3720    #[test]
3721    fn aft_radii_and_resolution_errors() {
3722        let boattail = |aft_shoulder: bool| {
3723            let mut c = body(
3724                "flare",
3725                Part::Transition(Transition {
3726                    shape: crate::NoseShape::Conical {},
3727                    clipped: false,
3728                    length_m: 0.1,
3729                    fore_radius_m: 0.03,
3730                    aft_radius_m: 0.0,
3731                    wall: crate::Wall::Shell { thickness_m: 0.002 },
3732                    fore_shoulder: None,
3733                    aft_shoulder: aft_shoulder.then_some(Shoulder {
3734                        length_m: 0.04,
3735                        outer_radius_m: 0.0,
3736                        thickness_m: 0.002,
3737                        capped: false,
3738                    }),
3739                    material: crate::testing::cardboard(),
3740                }),
3741            );
3742            c.auto = vec![AutoDimension::AftRadius, AutoDimension::AftShoulderRadius];
3743            c
3744        };
3745        let with = |flare: Component, last: Component| {
3746            rocket(vec![stage(
3747                "s",
3748                vec![
3749                    body("nose", nose(0.2, 0.03)),
3750                    body("upper", tube(0.5, 0.03, 0.001)),
3751                    flare,
3752                    last,
3753                ],
3754            )])
3755        };
3756        let layout = with(boattail(true), body("lower", tube(0.4, 0.04, 0.0015)))
3757            .layout()
3758            .unwrap();
3759        let Part::Transition(t) = layout.find("flare").unwrap().1.part.clone() else {
3760            panic!()
3761        };
3762        assert_eq!(t.aft_radius_m, 0.04);
3763        assert_eq!(t.aft_shoulder.unwrap().outer_radius_m, 0.04 - 0.0015);
3764
3765        let tree_id = |result: Result<Layout, DesignError>| match result {
3766            Err(DesignError::Tree { id, .. }) => id,
3767            other => panic!("{other:?}"),
3768        };
3769        // No shoulder to size.
3770        let lower = || body("lower", tube(0.4, 0.04, 0.0015));
3771        assert_eq!(tree_id(with(boattail(false), lower()).layout()), "flare");
3772        // A shoulder into a transition, not a tube.
3773        let mut cone = boattail(true);
3774        cone.id = "cone".to_owned();
3775        cone.auto = vec![AutoDimension::AftShoulderRadius];
3776        if let Part::Transition(t) = &mut cone.part {
3777            t.aft_radius_m = 0.02;
3778        }
3779        assert_eq!(tree_id(with(boattail(true), cone).layout()), "flare");
3780
3781        let mut no_nose = with(boattail(true), lower());
3782        no_nose.stages[0].components.remove(0);
3783        no_nose.reference_diameter = ReferenceDiameter::NoseBase {};
3784        assert!(matches!(no_nose.layout(), Err(DesignError::Tree { .. })));
3785
3786        // A body tube holding `n` nested inner tubes: `n + 1` levels.
3787        let nest = |n: usize| {
3788            let mut deepest = attached("t0", inner_tube(0.1, 0.02, 0.001), top(0.0));
3789            for k in 1..n {
3790                let mut outer = attached(&format!("t{k}"), inner_tube(0.1, 0.02, 0.001), top(0.0));
3791                outer.children = vec![deepest];
3792                deepest = outer;
3793            }
3794            let mut airframe = body("airframe", tube(0.5, 0.03, 0.001));
3795            airframe.children = vec![deepest];
3796            rocket(vec![stage("s", vec![airframe])])
3797        };
3798        nest(MAX_DEPTH - 1).layout().unwrap();
3799        assert_eq!(tree_id(nest(MAX_DEPTH).layout()), "t0");
3800
3801        // A part's own error names the part.
3802        let bad = with(boattail(true), body("lower", tube(0.4, 0.04, -0.001)));
3803        assert!(matches!(
3804            bad.layout(),
3805            Err(DesignError::InComponent { ref id, ref source })
3806                if id == "lower" && matches!(**source, DesignError::Domain { .. })
3807        ));
3808        let mut bad = with(boattail(true), lower());
3809        bad.stages[0].components[1].children = vec![attached(
3810            "lost",
3811            mass_component(0.1, 0.1, 0.01),
3812            top(f64::NAN),
3813        )];
3814        assert!(matches!(
3815            bad.layout(),
3816            Err(DesignError::InComponent { ref id, .. }) if id == "lost"
3817        ));
3818    }
3819
3820    /// An automatic packed radius fills the bore on the part's side of the axis, and an offset
3821    /// outside the bore is refused.
3822    #[test]
3823    fn packed_radius_leaves_room_for_the_offset() {
3824        let mut design = three_fin_rocket();
3825        if let Part::Parachute(chute) = &mut design.stages[0].components[1].children[4].part {
3826            chute.packing.radial_offset_m = 0.005;
3827        }
3828        let layout = design.layout().unwrap();
3829        let Part::Parachute(chute) = layout.find("chute").unwrap().1.part.clone() else {
3830            panic!()
3831        };
3832        close(
3833            chute.packing.radius_m,
3834            0.0255 - 0.005,
3835            1e-15,
3836            "packed radius",
3837        );
3838        assert!(crate::checks::check(&design).unwrap().is_empty());
3839        if let Part::Parachute(chute) = &mut design.stages[0].components[1].children[4].part {
3840            chute.packing.radial_offset_m = 0.03;
3841        }
3842        assert!(matches!(
3843            design.layout(),
3844            Err(DesignError::Tree { ref id, .. }) if id == "chute"
3845        ));
3846    }
3847
3848    #[test]
3849    fn design_round_trips_through_json() {
3850        let design = three_fin_rocket();
3851        let text = serde_json::to_string_pretty(&design).unwrap();
3852        let back: Rocket = serde_json::from_str(&text).unwrap();
3853        assert_eq!(back, design);
3854        assert_eq!(back.layout().unwrap(), design.layout().unwrap());
3855        // Unknown fields are refused, not dropped.
3856        let bad = text.replacen("\"auto\"", "\"autos\"", 1);
3857        assert!(serde_json::from_str::<Rocket>(&bad).is_err());
3858    }
3859
3860    /// A random spine for the property below: a kind (0 a nose cone, first only; 1 a tube; 2 a
3861    /// transition), whether each end is automatic, and each end's fixed radius in centimeters.
3862    type SpineSpec = Vec<(usize, bool, bool, u8, u8)>;
3863
3864    fn random_spine(spec: &SpineSpec, split: usize) -> Rocket {
3865        let mut components = Vec::new();
3866        for (k, &(kind, fore_auto, aft_auto, fore_cm, aft_cm)) in spec.iter().enumerate() {
3867            let (fore, aft) = (f64::from(fore_cm) * 0.01, f64::from(aft_cm) * 0.01);
3868            let id = format!("c{k}");
3869            let mut c = match kind {
3870                0 if k == 0 => body(&id, nose(0.2, aft)),
3871                2 => body(&id, cone_transition(fore, aft)),
3872                _ => body(&id, tube(0.3, aft, 0.001)),
3873            };
3874            c.auto = match &c.part {
3875                Part::NoseCone(_) if aft_auto => vec![AutoDimension::BaseRadius],
3876                Part::BodyTube(_) if aft_auto => vec![AutoDimension::OuterRadius],
3877                Part::Transition(_) => [
3878                    (fore_auto, AutoDimension::ForeRadius),
3879                    (aft_auto, AutoDimension::AftRadius),
3880                ]
3881                .into_iter()
3882                .filter_map(|(on, dimension)| on.then_some(dimension))
3883                .collect(),
3884                _ => Vec::new(),
3885            };
3886            components.push(c);
3887        }
3888        // Split into two stages somewhere, so the property crosses a stage boundary too.
3889        let split = split.clamp(1, components.len());
3890        let aft = components.split_off(split);
3891        let mut stages = vec![stage("upper", components)];
3892        if !aft.is_empty() {
3893            stages.push(stage("lower", aft));
3894        }
3895        rocket(stages)
3896    }
3897
3898    proptest! {
3899        /// Over random spines, `unresolvable_body_radii` is empty exactly when `layout` succeeds;
3900        /// filling what it lists is all `layout` then needs; and every radius the neighbour rule
3901        /// could already reach comes out as it did before the fill.
3902        #[test]
3903        fn unresolvable_radii_are_exactly_what_layout_refuses(
3904            spec in proptest::collection::vec((0usize..3, any::<bool>(), any::<bool>(), 1u8..5, 1u8..5), 1..7),
3905            split in 1usize..7,
3906        ) {
3907            let mut design = random_spine(&spec, split);
3908            let unresolvable = design.unresolvable_body_radii();
3909            prop_assert_eq!(unresolvable.is_empty(), design.layout().is_ok(), "{:?}", unresolvable);
3910
3911            let body = |design: &Rocket| -> (Vec<Part>, Vec<Vec<AutoDimension>>) {
3912                design
3913                    .stages
3914                    .iter()
3915                    .flat_map(|s| s.components.iter())
3916                    .map(|c| (c.part.clone(), c.auto.clone()))
3917                    .unzip()
3918            };
3919            let (parts, autos) = body(&design);
3920            let autos: Vec<&[AutoDimension]> = autos.iter().map(Vec::as_slice).collect();
3921            let (fore, aft) = sweep_body_radii(&parts, &autos);
3922
3923            let filled = design.fill_unresolvable_body_radii(0.025);
3924            prop_assert_eq!(&filled, &unresolvable);
3925            prop_assert!(design.unresolvable_body_radii().is_empty());
3926            let layout = design.layout();
3927            prop_assert!(layout.is_ok(), "{:?}", layout.as_ref().err());
3928            let layout = layout.unwrap();
3929            for (n, placed) in layout.body().enumerate() {
3930                let (f, a) = match &placed.part {
3931                    Part::NoseCone(p) => (None, Some(p.base_radius_m)),
3932                    Part::BodyTube(p) => (Some(p.outer_radius_m), Some(p.outer_radius_m)),
3933                    Part::Transition(p) => (Some(p.fore_radius_m), Some(p.aft_radius_m)),
3934                    _ => (None, None),
3935                };
3936                // A radius the sweep reached before the fill is the one layout gives after it.
3937                if let (Some(before), Some(after)) = (fore[n], f) {
3938                    prop_assert_eq!(before, after, "forward radius of {}", placed.id);
3939                }
3940                if let (Some(before), Some(after)) = (aft[n], a) {
3941                    prop_assert_eq!(before, after, "aft radius of {}", placed.id);
3942                }
3943            }
3944        }
3945
3946        /// Placed at random along a tube and rolled, point-like masses sum to the structure's mass
3947        /// and center, and sliding every part aft by `d` slides the center by `d` without changing
3948        /// the tensor.
3949        #[test]
3950        fn structure_is_the_mass_weighted_sum_and_slides_rigidly(
3951            parts in proptest::collection::vec((0.01f64..2.0, 0.0f64..0.7, 0.0f64..0.02, -3.0f64..3.0), 1..6),
3952            d in -0.1f64..0.1,
3953        ) {
3954            let build = |shift: f64| {
3955                let mut airframe = body("airframe", tube(1.0, 0.03, 0.001));
3956                airframe.children = parts
3957                    .iter()
3958                    .enumerate()
3959                    .map(|(k, &(m, s, r, angle))| {
3960                        let mut c = attached(&format!("m{k}"), mass_component(m, 0.05, 0.005), top(s + shift));
3961                        if let Part::MassComponent(p) = &mut c.part {
3962                            p.packing.radial_offset_m = r;
3963                            p.packing.angle_rad = angle;
3964                        }
3965                        c
3966                    })
3967                    .collect();
3968                // A massless tube leaves only the masses.
3969                if let Part::BodyTube(t) = &mut airframe.part {
3970                    t.material = crate::Material::bulk("none", 0.0);
3971                }
3972                rocket(vec![stage("s", vec![airframe])]).layout().unwrap()
3973            };
3974            let layout = build(0.0);
3975            let total: f64 = parts.iter().map(|p| p.0).sum();
3976            prop_assert!((layout.structure.mass_kg - total).abs() <= 1e-12 * total);
3977            let moment: f64 = parts.iter().map(|&(m, s, _, _)| m * -(s + 0.025)).sum();
3978            prop_assert!((layout.structure.cg_m.z - moment / total).abs() <= 1e-12);
3979            let slid = build(d);
3980            prop_assert!((slid.structure.cg_m.z - (layout.structure.cg_m.z - d)).abs() <= 1e-12);
3981            let scale = layout.structure.inertia_kg_m2.to_cols_array().iter().fold(1e-12f64, |m, v| m.max(v.abs()));
3982            let diff = (slid.structure.inertia_kg_m2 - layout.structure.inertia_kg_m2)
3983                .to_cols_array()
3984                .iter()
3985                .fold(0.0f64, |m, v| m.max(v.abs()));
3986            prop_assert!(diff <= 1e-9 * scale, "{diff:e} of {scale:e}");
3987        }
3988    }
3989
3990    /// A layout written before the move to US spelling, its parts and stages holding
3991    /// `centre_overridden`, reads as the same layout: a part and a stage that set an axial center
3992    /// hold `true` under the old key, which a missing alias would read as `false`.
3993    #[test]
3994    fn a_layout_with_the_old_uk_key_reads_the_same() {
3995        let mut design = three_fin_rocket();
3996        design.stages[0].overrides.cg_aft_m = Some(0.4);
3997        design.stages[0].components[0].overrides.cg_aft_m = Some(0.1);
3998        let layout = design.layout().unwrap();
3999        assert!(layout.stages[0].center_overridden);
4000        let text = serde_json::to_string(&layout).unwrap();
4001        let old = text.replace("\"center_overridden\"", "\"centre_overridden\"");
4002        assert!(!old.contains("\"center_overridden\""));
4003        assert!(
4004            old.matches("\"centre_overridden\":true").count() >= 2,
4005            "a part and a stage: {old}"
4006        );
4007        assert_eq!(serde_json::from_str::<Layout>(&old).unwrap(), layout);
4008    }
4009}