Skip to main content

hpr_design/
checks.rs

1//! Structural design checks: typed findings about a design that resolves but can't be built or
2//! flown as described.
3//!
4//! A finding is an [`Severity::Error`] when the design is physically impossible and a simulation of
5//! it would be wrong, usually on the flattering side: a motor wider than its mount (Loft reported
6//! +69% apogee for one), or fins whose root touches no part of the tube they belong to. A
7//! [`Severity::Warning`] marks something unusual that can be real, such as a motor mount that
8//! sticks out of the airframe. Callers that simulate should refuse designs with errors.
9//!
10//! Lengths are compared with [`LENGTH_TOLERANCE_M`] of slack, so round-off never raises a finding.
11//!
12//! See `docs/physics/design.md`.
13
14use serde::{Deserialize, Serialize};
15
16use crate::config::{Configuration, LaidOut};
17use crate::error::DesignError;
18use crate::solids::Wall;
19use crate::tree::{LENGTH_TOLERANCE_M, Layout, Part, PlacedComponent, Rocket};
20
21/// The tolerance of a three-place inch dimension on AeroTech's RMS dimensional drawings, m:
22/// 0.005 in (0.127 mm), the `.XXX` entry of their title blocks.
23pub const DRAWING_TOLERANCE_M: f64 = 0.005 * 0.0254;
24
25/// ISO 2768-1:1989, table 1, tolerance class *c* (coarse): the permissible deviation of a linear
26/// dimension with no tolerance of its own. Each entry is `(upper size, ± deviation)`, m, for sizes
27/// above the previous entry's and up to and including this one's; the first band starts at
28/// 0.5 mm, included, below which the standard sets none. The standard is for machined parts; hpr borrows
29/// its coarse class for hobby airframe parts, which no standard covers.
30pub const COARSE_GENERAL_TOLERANCE_M: [(f64, f64); 8] = [
31    (0.003, 0.0002),
32    (0.006, 0.0003),
33    (0.030, 0.0005),
34    (0.120, 0.0008),
35    (0.400, 0.0012),
36    (1.000, 0.002),
37    (2.000, 0.003),
38    (4.000, 0.004),
39];
40
41/// How far an internal part may reach past the room in its parent and still be a fit, m: the
42/// coarse general tolerance ([`COARSE_GENERAL_TOLERANCE_M`]) of the room's diameter. A part
43/// drawn that much wider than a bore of that size, both made to the tolerance, can be made as a
44/// fit: the bore at `+t` and the part at `−t` close a diametral interference of `2t`, a radial one
45/// of `t`. 0.5 mm for a bore of 6 to 30 mm, 0.8 mm for 30 to 120 mm, 1.2 mm for 120 to 400 mm;
46/// none below 0.5 mm, and 4 mm past 4 m.
47pub fn fit_tolerance_m(room_m: f64) -> f64 {
48    let diameter_m = 2.0 * room_m;
49    // A room that isn't a number gets no tolerance.
50    if diameter_m.is_nan() || diameter_m < 0.0005 {
51        return 0.0;
52    }
53    COARSE_GENERAL_TOLERANCE_M
54        .iter()
55        .find(|(upto, _)| diameter_m <= *upto)
56        .map_or(0.004, |(_, t)| *t)
57}
58
59/// The nominal motor sizes whose cases are narrower than their names, with the largest case
60/// outside diameter, m: AeroTech's RMS dimensional drawings (RMS-18/20, RMS-24/40, RMS-29/40-120
61/// and HP RMS-29/120, archived from aerotech-rocketry.com in 2005; inches, `.XXX` to
62/// [`DRAWING_TOLERANCE_M`]) give the cases as 0.698, 0.938 and 1.125 in across, so at most 0.703,
63/// 0.943 and 1.130 in. Each entry is `(nominal size, largest case)`. ThrustCurve.org and RASP
64/// files carry the nominal size, so a "29 mm" motor is 29 mm in hpr, and an AeroTech RMS-29 case
65/// at most 28.70 mm in the hand; other makers' cases may differ. The same drawings give 38, 54,
66/// 75 and 98 mm cases as 1.500, 2.125, 2.965 and 3.870 in, so at the +0.005 in limit each is at
67/// least as wide as its name, and those sizes get no slack.
68pub const NARROWER_THAN_NOMINAL_M: [(f64, f64); 3] = [
69    (0.018, 0.698 * 0.0254 + DRAWING_TOLERANCE_M),
70    (0.024, 0.938 * 0.0254 + DRAWING_TOLERANCE_M),
71    (0.029, 1.125 * 0.0254 + DRAWING_TOLERANCE_M),
72];
73
74/// How much wider than its mount's bore a motor's diameter may be and only warn, m: for a nominal
75/// size in [`NARROWER_THAN_NOMINAL_M`], how much wider the name is than the largest case
76/// (0.144 mm at 18 mm, 0.048 mm at 24 mm, 0.298 mm at 29 mm, so a 29 mm motor fits a 1.140 in
77/// bore); for any other diameter, none.
78pub fn motor_fit_slack_m(diameter_m: f64) -> f64 {
79    NARROWER_THAN_NOMINAL_M
80        .iter()
81        .find(|(nominal, _)| (diameter_m - nominal).abs() <= LENGTH_TOLERANCE_M)
82        .map_or(0.0, |(nominal, case)| nominal - case)
83}
84
85/// How serious a finding is.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Severity {
89    /// Unusual, but it can be built and flown.
90    Warning,
91    /// Impossible as described; a simulation would be wrong.
92    Error,
93}
94
95/// A problem found in a design. Serialized with a `kind` tag.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98#[non_exhaustive]
99pub enum Finding {
100    /// The motor case is wider than the mount's inside diameter by more than
101    /// [`motor_fit_slack_m`] allows (error).
102    MotorWiderThanMount {
103        /// Configuration id.
104        configuration: String,
105        /// Mount id.
106        mount: String,
107        /// Motor case diameter, m.
108        motor_diameter_m: f64,
109        /// Mount inside diameter, m.
110        mount_inner_diameter_m: f64,
111    },
112    /// The motor's nominal diameter is wider than the mount's inside diameter, but by no more than
113    /// [`motor_fit_slack_m`] allows: the real case fits, such as a 29 mm motor in a 1.140 in
114    /// (28.956 mm) bore (warning).
115    MotorTightInMount {
116        /// Configuration id.
117        configuration: String,
118        /// Mount id.
119        mount: String,
120        /// The motor's nominal diameter, m.
121        motor_diameter_m: f64,
122        /// Mount inside diameter, m.
123        mount_inner_diameter_m: f64,
124    },
125    /// The motor case doesn't overlap its mount along the axis at all, such as an overhang typed
126    /// in millimeters as meters (error).
127    MotorOutsideMount {
128        /// Configuration id.
129        configuration: String,
130        /// Mount id.
131        mount: String,
132    },
133    /// The motor case reaches forward past the mount's forward end (warning).
134    MotorPastMountTop {
135        /// Configuration id.
136        configuration: String,
137        /// Mount id.
138        mount: String,
139        /// How far past, m.
140        excess_m: f64,
141    },
142    /// An external part (fin root, tube fins, lug, rail button or pod set) doesn't overlap the body
143    /// tube it is attached to at all (error). A pod set of no length touches its tube when it sits
144    /// between the tube's ends, and a part on a pod's tube of no length when it spans the tube's
145    /// station.
146    AttachmentOffBody {
147        /// Component id.
148        component: String,
149        /// Body tube id.
150        body: String,
151    },
152    /// An external part runs past an end of its body tube (warning). Pods are exempt: a pod hangs
153    /// from a pylon and often runs past its tube. So is a part on a pod's tube of no length, which
154    /// has no length to run past.
155    AttachmentPastBodyEnd {
156        /// Component id.
157        component: String,
158        /// Body tube id.
159        body: String,
160        /// How far past, m.
161        excess_m: f64,
162    },
163    /// An internal part lies wholly forward of the nose tip or aft of the rocket's end, and touches
164    /// none of the parts it hangs from (a motor mount may stick out) (error).
165    PartOutsideRocket {
166        /// Component id.
167        component: String,
168    },
169    /// An internal part runs past an end of its parent (warning).
170    InternalPartPastParentEnd {
171        /// Component id.
172        component: String,
173        /// Parent id.
174        parent: String,
175        /// How far past, m.
176        excess_m: f64,
177    },
178    /// An internal part reaches farther from its parent's axis than the parent has room, a tube's
179    /// bore or a nose cone's or transition's inside radius along the part (for a rigid part the
180    /// most room along it, for a packed part the least), by more than
181    /// [`fit_tolerance_m`] (error). A centering ring that wraps its parent inner tube, on the
182    /// tube's axis with its bore at least the tube's outside, is measured against what holds the
183    /// tube instead.
184    InternalPartWiderThanParent {
185        /// Component id.
186        component: String,
187        /// The id of the part whose room it needs: its parent, or what holds the tube it wraps.
188        parent: String,
189        /// How far the part reaches from the parent's axis, m.
190        reach_m: f64,
191        /// The room in the parent, m.
192        room_m: f64,
193    },
194    /// An internal part reaches past the room in its parent by no more than [`fit_tolerance_m`]: a
195    /// fit to sand. The mass where it crosses its parent's wall counts twice (warning). A ring
196    /// wrapping its parent tube is measured as for [`Finding::InternalPartWiderThanParent`].
197    InternalPartTightInParent {
198        /// Component id.
199        component: String,
200        /// The id of the part whose room it needs: its parent, or what holds the tube it wraps.
201        parent: String,
202        /// How far the part reaches from the parent's axis, m.
203        reach_m: f64,
204        /// The room in the parent, m.
205        room_m: f64,
206    },
207    /// An internal part other than a packed part fits inside a nose cone or transition where the
208    /// profile is widest along the part, to [`fit_tolerance_m`], but reaches into the wall where
209    /// the profile narrows, past that tolerance: as drawn it can't slide that far in (warning,
210    /// #313). It flies where it is drawn, so its mass sits a little nearer the narrow end than it
211    /// could.
212    InternalPartWedgedInParent {
213        /// Component id.
214        component: String,
215        /// Parent id.
216        parent: String,
217        /// How far the part reaches from the parent's axis, m.
218        reach_m: f64,
219        /// The room where the profile is narrowest along the part, m.
220        room_m: f64,
221        /// The room where the profile is widest along the part, m.
222        widest_room_m: f64,
223    },
224    /// A packed part (a mass component, parachute, streamer or shock cord) reaches past the room
225    /// in its parent, its center inside the room (warning). Its width is how it is packed, which
226    /// sets only its own share of the rocket's inertia; its mass, length and station fly as drawn.
227    PackedPartWiderThanParent {
228        /// Component id.
229        component: String,
230        /// Parent id.
231        parent: String,
232        /// How far the part reaches from the parent's axis, m.
233        reach_m: f64,
234        /// The room in the parent, m.
235        room_m: f64,
236        /// Whether the parent has more room aft of the part than along it, as a nose cone or a
237        /// shoulder does: the part's mass can sit only farther aft than drawn, so its center of
238        /// gravity flies forward of where it can be and the stability margin may read high
239        /// ([issue #367](https://github.com/nrdptel/hpr-sim/issues/367)). Read as `false` from a
240        /// finding saved before it was kept.
241        #[serde(default)]
242        room_widens_aft: bool,
243    },
244    /// A centering ring or bulkhead wider than its parent's room, past [`fit_tolerance_m`], sits
245    /// at one of its parent's end faces and fits what holds its parent, to that tolerance: a cap
246    /// glued against the end, such as a bulkhead sized to the airframe on a coupler's end. The
247    /// mass where it crosses its parent's wall counts twice (warning).
248    RingAgainstParentEnd {
249        /// The ring's id.
250        ring: String,
251        /// Parent id.
252        parent: String,
253        /// How far the ring reaches from the axis of what holds its parent, m.
254        reach_m: f64,
255        /// The room in what holds its parent, m.
256        room_m: f64,
257    },
258    /// Two tubes of a cluster are closer than a tube's diameter, so they cross: the mass where they
259    /// cross is counted twice and their motors would not fit (warning).
260    ClusterTubesOverlap {
261        /// Inner tube id.
262        tube: String,
263        /// The distance between the closest two tubes' axes, m.
264        apart_m: f64,
265        /// The tube's outer diameter, m.
266        diameter_m: f64,
267    },
268    /// A centering ring overlaps an inner tube beside it, so the mass where they cross is counted
269    /// twice; an automatic inner radius only clears on-axis tubes (warning).
270    RingOverlapsInnerTube {
271        /// The ring's id.
272        ring: String,
273        /// The tube's id.
274        tube: String,
275    },
276    /// A stage with an axial center-of-mass override (`cg_aft_m`), its own or a component's, has
277    /// its center forward of the nose tip or aft of the rocket's end although every internal part
278    /// in it is on the rocket, such as a center typed in millimeters as meters (error). Written
279    /// before the move to US spelling as `centre_outside_rocket`, which is still read and never written.
280    #[serde(alias = "centre_outside_rocket")]
281    CenterOutsideRocket {
282        /// The stage's id.
283        stage: String,
284        /// The center's station, m.
285        station_m: f64,
286    },
287    /// Adjacent body components' radii differ where they meet (warning).
288    RadiusStep {
289        /// The forward component's id.
290        fore: String,
291        /// The aft component's id.
292        aft: String,
293        /// The forward component's aft radius, m.
294        fore_radius_m: f64,
295        /// The aft component's forward radius, m.
296        aft_radius_m: f64,
297    },
298    /// The first body component is not a nose cone (warning).
299    NoNoseCone {
300        /// The first component's id.
301        component: String,
302    },
303}
304
305impl Finding {
306    /// The finding's severity.
307    pub fn severity(&self) -> Severity {
308        match self {
309            Self::MotorWiderThanMount { .. }
310            | Self::MotorOutsideMount { .. }
311            | Self::AttachmentOffBody { .. }
312            | Self::PartOutsideRocket { .. }
313            | Self::InternalPartWiderThanParent { .. }
314            | Self::CenterOutsideRocket { .. } => Severity::Error,
315            Self::MotorTightInMount { .. }
316            | Self::MotorPastMountTop { .. }
317            | Self::AttachmentPastBodyEnd { .. }
318            | Self::InternalPartPastParentEnd { .. }
319            | Self::InternalPartTightInParent { .. }
320            | Self::InternalPartWedgedInParent { .. }
321            | Self::PackedPartWiderThanParent { .. }
322            | Self::RingAgainstParentEnd { .. }
323            | Self::ClusterTubesOverlap { .. }
324            | Self::RingOverlapsInnerTube { .. }
325            | Self::RadiusStep { .. }
326            | Self::NoNoseCone { .. } => Severity::Warning,
327        }
328    }
329
330    /// The finding as a sentence, without its severity: the parts by `name`, which is given a
331    /// component's, stage's or configuration's id; sizes in millimeters.
332    ///
333    /// ```
334    /// use hpr_design::checks::Finding;
335    ///
336    /// let finding = Finding::InternalPartPastParentEnd {
337    ///     component: "c1".to_owned(),
338    ///     parent: "c2".to_owned(),
339    ///     excess_m: 0.0508,
340    /// };
341    /// let name = |id: &str| if id == "c1" { "Shock cord" } else { "Body tube" }.to_owned();
342    /// assert_eq!(
343    ///     finding.describe(&name),
344    ///     "Shock cord runs 50.8 mm past an end of Body tube, which holds it"
345    /// );
346    /// ```
347    pub fn describe(&self, name: &dyn Fn(&str) -> String) -> String {
348        match self {
349            Self::MotorWiderThanMount {
350                configuration,
351                mount,
352                motor_diameter_m,
353                mount_inner_diameter_m,
354            } => {
355                let (motor, bore) = mm_pair(*motor_diameter_m, *mount_inner_diameter_m);
356                format!(
357                    "the motor in {} is {motor} wide, wider than the mount's {bore} bore by more \
358                     than a real case's slack (configuration {})",
359                    name(mount),
360                    name(configuration)
361                )
362            }
363            Self::MotorTightInMount {
364                configuration,
365                mount,
366                motor_diameter_m,
367                mount_inner_diameter_m,
368            } => {
369                let (motor, bore) = mm_pair(*motor_diameter_m, *mount_inner_diameter_m);
370                format!(
371                    "the motor in {} is nominally {motor} wide in a {bore} bore: it fits, as real \
372                     cases run under their nominal size (configuration {})",
373                    name(mount),
374                    name(configuration)
375                )
376            }
377            Self::MotorOutsideMount {
378                configuration,
379                mount,
380            } => format!(
381                "the motor in {} lies wholly outside the mount along its length, such as an \
382                 overhang typed in millimeters as meters (configuration {})",
383                name(mount),
384                name(configuration)
385            ),
386            Self::MotorPastMountTop {
387                configuration,
388                mount,
389                excess_m,
390            } => format!(
391                "the motor in {} reaches {} past the mount's forward end (configuration {})",
392                name(mount),
393                mm(*excess_m),
394                name(configuration)
395            ),
396            Self::AttachmentOffBody { component, body } => format!(
397                "{} doesn't touch {}, the body tube it is attached to",
398                name(component),
399                name(body)
400            ),
401            Self::AttachmentPastBodyEnd {
402                component,
403                body,
404                excess_m,
405            } => format!(
406                "{} runs {} past an end of {}, the body tube it is attached to",
407                name(component),
408                mm(*excess_m),
409                name(body)
410            ),
411            Self::PartOutsideRocket { component } => format!(
412                "{} lies wholly forward of the nose tip or aft of the rocket's end",
413                name(component)
414            ),
415            Self::InternalPartPastParentEnd {
416                component,
417                parent,
418                excess_m,
419            } => format!(
420                "{} runs {} past an end of {}, which holds it",
421                name(component),
422                mm(*excess_m),
423                name(parent)
424            ),
425            Self::InternalPartWiderThanParent {
426                component,
427                parent,
428                reach_m,
429                room_m,
430            } => {
431                let (reach, room) = mm_pair(*reach_m, *room_m);
432                format!(
433                    "{} reaches {reach} from the axis of {}, which has {room} of room: too wide \
434                     to fit, past the fit tolerance",
435                    name(component),
436                    name(parent)
437                )
438            }
439            Self::InternalPartTightInParent {
440                component,
441                parent,
442                reach_m,
443                room_m,
444            } => {
445                let (reach, room) = mm_pair(*reach_m, *room_m);
446                format!(
447                    "{} reaches {reach} from the axis of {}, which has {room} of room: a fit to \
448                     sand, and the mass where it crosses the wall counts twice",
449                    name(component),
450                    name(parent)
451                )
452            }
453            Self::InternalPartWedgedInParent {
454                component,
455                parent,
456                reach_m,
457                room_m,
458                widest_room_m,
459            } => {
460                let (reach, room) = mm_pair(*reach_m, *room_m);
461                let (_, widest) = mm_pair(*reach_m, *widest_room_m);
462                format!(
463                    "{} reaches {reach} from the axis of {}, which narrows to {room} of room \
464                     along it ({widest} where widest): it can't slide in that far as drawn, and \
465                     flies where it is drawn",
466                    name(component),
467                    name(parent)
468                )
469            }
470            Self::PackedPartWiderThanParent {
471                component,
472                parent,
473                reach_m,
474                room_m,
475                room_widens_aft,
476            } => {
477                let (reach, room) = mm_pair(*reach_m, *room_m);
478                let mut text = format!(
479                    "{} is packed to reach {reach} from the axis of {}, which has {room} of \
480                     room: it can't go in as drawn; its mass, length and station fly as drawn, and \
481                     its width sets only its own inertia",
482                    name(component),
483                    name(parent)
484                );
485                if *room_widens_aft {
486                    text.push_str(
487                        ". The room widens aft of it, so its mass can sit only farther aft: the \
488                         center of gravity flies forward of where it can be, and the stability \
489                         margin may read high (issue #367, \
490                         https://github.com/nrdptel/hpr-sim/issues/367)",
491                    );
492                }
493                text
494            }
495            Self::RingAgainstParentEnd {
496                ring,
497                parent,
498                reach_m,
499                room_m,
500            } => {
501                let (reach, room) = mm_pair(*reach_m, *room_m);
502                format!(
503                    "{} reaches {reach} from the axis, wider than {}, and caps its end, in \
504                     {room} of room around it; the mass where it crosses the wall counts twice",
505                    name(ring),
506                    name(parent)
507                )
508            }
509            Self::ClusterTubesOverlap {
510                tube,
511                apart_m,
512                diameter_m,
513            } => {
514                let (apart, diameter) = mm_pair(*apart_m, *diameter_m);
515                format!(
516                    "the cluster of {} has tubes {apart} apart, closer than their {diameter} \
517                     diameter, so they cross: the mass where they cross counts twice and their \
518                     motors would not fit",
519                    name(tube)
520                )
521            }
522            Self::RingOverlapsInnerTube { ring, tube } => format!(
523                "{} overlaps {} beside it, so the mass where they cross counts twice",
524                name(ring),
525                name(tube)
526            ),
527            Self::CenterOutsideRocket { stage, station_m } => format!(
528                "the center of mass set for {} is {} {} the nose tip, outside the rocket, such \
529                 as a center typed in millimeters as meters",
530                name(stage),
531                mm(station_m.abs()),
532                if *station_m < 0.0 {
533                    "forward of"
534                } else {
535                    "aft of"
536                }
537            ),
538            Self::RadiusStep {
539                fore,
540                aft,
541                fore_radius_m,
542                aft_radius_m,
543            } => {
544                let (fore_radius, aft_radius) = mm_pair(*fore_radius_m, *aft_radius_m);
545                format!(
546                    "{} ends {fore_radius} in radius where {} begins {aft_radius}: a step in the \
547                     body's outline",
548                    name(fore),
549                    name(aft)
550                )
551            }
552            Self::NoNoseCone { component } => format!(
553                "the rocket's first body part, {}, is not a nose cone",
554                name(component)
555            ),
556        }
557    }
558}
559
560/// A length in millimeters: to a tenth, or under 1 mm to a hundredth, or under 0.1 mm to a
561/// thousandth.
562fn mm(length_m: f64) -> String {
563    let mm = length_m * 1000.0;
564    format!("{mm:.prec$} mm", prec = decimals(mm))
565}
566
567/// The decimals [`mm`] gives a size in millimeters.
568fn decimals(mm: f64) -> usize {
569    match mm.abs() {
570        size if size >= 1.0 || size == 0.0 => 1,
571        size if size >= 0.1 => 2,
572        _ => 3,
573    }
574}
575
576/// Two lengths a sentence compares, in millimeters, to as many decimals as tell them apart, up to
577/// a ten-thousandth (0.1 µm): a 29 mm motor in a 28.956 mm bore is `29.00 mm` in a `28.96 mm`,
578/// not `29.0 mm` in a `29.0 mm`.
579fn mm_pair(a_m: f64, b_m: f64) -> (String, String) {
580    let (a, b) = (a_m * 1000.0, b_m * 1000.0);
581    // The finer of the two, so a small size beside a large one keeps its digits.
582    let mut prec = decimals(a).max(decimals(b));
583    while prec < 4 && format!("{a:.prec$}") == format!("{b:.prec$}") {
584        prec += 1;
585    }
586    (format!("{a:.prec$} mm"), format!("{b:.prec$} mm"))
587}
588
589/// Every check on a design and all its configurations.
590///
591/// # Errors
592///
593/// As [`Rocket::layout`], [`Rocket::check_configuration_ids`] and [`Layout::place_motors`]: a
594/// design that doesn't resolve can't be checked.
595pub fn check(rocket: &Rocket) -> Result<Vec<Finding>, DesignError> {
596    rocket.check_configuration_ids()?;
597    check_on(rocket, &rocket.layout()?)
598}
599
600impl LaidOut {
601    /// As [`check`], on this layout.
602    ///
603    /// # Errors
604    ///
605    /// As [`Layout::place_motors`].
606    pub fn check(&self) -> Result<Vec<Finding>, DesignError> {
607        check_on(self.rocket(), self.layout())
608    }
609}
610
611/// Every check on `rocket`, whose layout is `layout`.
612fn check_on(rocket: &Rocket, layout: &Layout) -> Result<Vec<Finding>, DesignError> {
613    let mut findings = check_layout(layout);
614    for configuration in &rocket.configurations {
615        findings.extend(check_configuration(layout, configuration)?);
616    }
617    Ok(findings)
618}
619
620/// Whether any finding is an error.
621pub fn has_errors(findings: &[Finding]) -> bool {
622    findings.iter().any(|f| f.severity() == Severity::Error)
623}
624
625/// The overlap of `[a0, a1]` and `[b0, b1]`, m (negative when they are apart).
626fn overlap(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
627    a1.min(b1) - a0.max(b0)
628}
629
630/// How far `[fore, aft]` runs past `[parent_fore, parent_aft]`, m, or `None` when the two don't
631/// overlap at all.
632fn excess(fore: f64, aft: f64, parent_fore: f64, parent_aft: f64) -> Option<f64> {
633    if overlap(fore, aft, parent_fore, parent_aft) <= 0.0 {
634        return None;
635    }
636    Some((parent_fore - fore).max(aft - parent_aft).max(0.0))
637}
638
639/// The checks on the structure alone.
640///
641/// The stage-center check reads the `center_overridden` flags that [`crate::Rocket::layout`] sets;
642/// a layout built by hand without them never raises [`Finding::CenterOutsideRocket`].
643pub fn check_layout(layout: &Layout) -> Vec<Finding> {
644    let mut findings = Vec::new();
645    let body: Vec<_> = layout.body().collect();
646    if let Some(first) = body.first()
647        && !matches!(first.part, Part::NoseCone(_))
648    {
649        findings.push(Finding::NoNoseCone {
650            component: first.id.clone(),
651        });
652    }
653    for pair in body.windows(2) {
654        let (fore, aft) = (pair[0], pair[1]);
655        if let (Some(a), Some(b)) = (fore.part.aft_radius_m(), aft.part.fore_radius_m())
656            && (a - b).abs() > LENGTH_TOLERANCE_M
657        {
658            findings.push(Finding::RadiusStep {
659                fore: fore.id.clone(),
660                aft: aft.id.clone(),
661                fore_radius_m: a,
662                aft_radius_m: b,
663            });
664        }
665    }
666    // Internal parts wholly off the rocket and off every part they hang from, and the components
667    // holding one.
668    let length = layout.length_m;
669    // A part that only touches a span at an end face is on it, within round-off.
670    let apart = |c: &PlacedComponent, fore: f64, aft: f64| {
671        overlap(c.fore_station_m, c.aft_station_m(), fore, aft) < -LENGTH_TOLERANCE_M
672    };
673    let off_rocket: Vec<bool> = layout
674        .components
675        .iter()
676        .map(|c| {
677            // A pod's body components are outside the airframe, like the pod set they hang from.
678            if c.parent.is_none()
679                || c.part.is_external()
680                || c.part.is_body()
681                || !apart(c, 0.0, length)
682            {
683                return false;
684            }
685            let mut ancestor = c.parent;
686            for _ in 0..layout.components.len() {
687                let Some(a) = ancestor.and_then(|i| layout.components.get(i)) else {
688                    break;
689                };
690                if !apart(c, a.fore_station_m, a.aft_station_m()) {
691                    return false;
692                }
693                ancestor = a.parent;
694            }
695            true
696        })
697        .collect();
698    let mut holds_off = off_rocket.clone();
699    for (index, component) in layout.components.iter().enumerate().rev() {
700        if holds_off[index]
701            && let Some(flag) = component.parent.and_then(|p| holds_off.get_mut(p))
702        {
703            *flag = true;
704        }
705    }
706    for (k, stage) in layout.stages.iter().enumerate() {
707        let station = -stage.mass.cg_m.z;
708        let in_stage = || {
709            layout
710                .components
711                .iter()
712                .enumerate()
713                .filter(|(_, c)| c.stage == k)
714        };
715        let holds = in_stage().any(|(i, _)| holds_off[i]);
716        let overridden = stage.center_overridden || in_stage().any(|(_, c)| c.center_overridden);
717        if overridden
718            && !holds
719            && stage.mass.mass_kg > 0.0
720            && !(-LENGTH_TOLERANCE_M..=length + LENGTH_TOLERANCE_M).contains(&station)
721        {
722            findings.push(Finding::CenterOutsideRocket {
723                stage: stage.id.clone(),
724                station_m: station,
725            });
726        }
727    }
728    for (index, component) in layout.components.iter().enumerate() {
729        let Some(parent) = component.parent.and_then(|i| layout.components.get(i)) else {
730            continue;
731        };
732        attached_findings(component, parent, off_rocket[index], layout, &mut findings);
733    }
734    findings
735}
736
737/// Interior stations at which [`room_m`] samples a profile across a part's span, besides the
738/// span's two ends.
739const ROOM_SAMPLES: usize = 31;
740
741/// The room for internal parts in a parent, m, along a part's span.
742#[derive(Debug, Clone, Copy)]
743struct Room {
744    /// Where the parent is narrowest along the part.
745    least_m: f64,
746    /// Where the parent is widest along the part.
747    most_m: f64,
748}
749
750/// The room for an internal part spanning stations `[fore, aft]` in `part`: a tube's bore, or a
751/// nose cone's or transition's inside radius along the part (#313). That is the profile's outer
752/// radius less its wall (none when filled, and none where the wall closes in at a tip), its least
753/// and most over the part's span within the profile: the span's two ends and [`ROOM_SAMPLES`]
754/// stations between them. Every profile hpr draws is concave (its radius never dips between two
755/// stations), so the least is at an end; the samples guard that claim. The wall is measured
756/// normal to the surface, so outer less wall overstates the inside radius by `t (1/cos θ − 1)` on
757/// a slope `θ`: 1% of the wall at 8°, 0.1 mm of a 3 mm wall at 15°. An automatic radius in a
758/// profile is its inside radius at the part's narrower end ([ADR-096][adr-096]), so it fits by
759/// this measure.
760///
761/// A part that runs past the profile's ends is measured over the part of it inside them (the
762/// rest is [`Finding::InternalPartPastParentEnd`]). One wholly outside them, or with length that
763/// only touches an end from outside, is measured against
764/// the profile's largest outer radius, as before #313: where it sits is not in the profile, and
765/// the past-the-end finding already names it. `None` for a part with no room for internal parts,
766/// or a profile that can't be drawn.
767///
768/// [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0096-fin-fillets-and-an-automatic-radius-inside-a.md
769fn room_m(part: &PlacedComponent, fore: f64, aft: f64) -> Option<Room> {
770    let both = |room_m: f64| Room {
771        least_m: room_m,
772        most_m: room_m,
773    };
774    if let Some(bore) = part.part.inner_radius_m() {
775        return Some(both(bore));
776    }
777    let (profile, wall) = match &part.part {
778        Part::NoseCone(nose) => (nose.profile().ok()?, nose.wall),
779        Part::Transition(transition) => (transition.profile().ok()?, transition.wall),
780        _ => return None,
781    };
782    let length = part.length_m;
783    let (x0, x1) = (fore - part.fore_station_m, aft - part.fore_station_m);
784    // Outside: no length of the part inside the profile. A part of no length at an end is inside,
785    // at that end: at a nose's tip it has no room.
786    let inside_m = x1.min(length) - x0.max(0.0);
787    if x1 < -LENGTH_TOLERANCE_M
788        || x0 > length + LENGTH_TOLERANCE_M
789        || (x1 - x0 > LENGTH_TOLERANCE_M && inside_m <= LENGTH_TOLERANCE_M)
790    {
791        return Some(both(profile.max_radius_m()));
792    }
793    let wall_m = match wall {
794        Wall::Filled {} => return Some(both(0.0)),
795        Wall::Shell { thickness_m } => thickness_m,
796    };
797    let (x0, x1) = (x0.clamp(0.0, length), x1.clamp(0.0, length));
798    let (least, most) = (0..=ROOM_SAMPLES + 1)
799        .map(|i| {
800            let x = x0 + (x1 - x0) * (i as f64) / ((ROOM_SAMPLES + 1) as f64);
801            profile.radius_m(x)
802        })
803        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), r| {
804            (lo.min(r), hi.max(r))
805        });
806    Some(Room {
807        least_m: (least - wall_m).max(0.0),
808        most_m: (most - wall_m).max(0.0),
809    })
810}
811
812/// What surrounds a centering ring that wraps `tube` over the ring's span `[fore, aft]`: out from
813/// the tube, the first part with room for parts whose span overlaps the ring's, if it holds the
814/// whole ring; `None` when one holds only part of it or none overlaps it, and the ring is measured
815/// in the tube.
816fn wrapped_ring_container<'a>(
817    fore: f64,
818    aft: f64,
819    tube: &PlacedComponent,
820    layout: &'a Layout,
821) -> Option<&'a PlacedComponent> {
822    let mut next = tube.parent;
823    while let Some(part) = next.and_then(|i| layout.components.get(i)) {
824        if room_m(part, fore, aft).is_some()
825            && let Some(e) = excess(fore, aft, part.fore_station_m, part.aft_station_m())
826        {
827            return (e <= LENGTH_TOLERANCE_M).then_some(part);
828        }
829        next = part.parent;
830    }
831    None
832}
833
834/// The checks on one attached part.
835fn attached_findings(
836    component: &PlacedComponent,
837    parent: &PlacedComponent,
838    off_rocket: bool,
839    layout: &Layout,
840    findings: &mut Vec<Finding>,
841) {
842    // A pod's body components stack along it, so they span the pod set exactly.
843    if component.part.is_body() {
844        return;
845    }
846    let (fore, aft) = (component.fore_station_m, component.aft_station_m());
847    // Whether `[a0, a1]` holds the station `x`, to round-off.
848    let holds =
849        |a0: f64, a1: f64, x: f64| a0 - LENGTH_TOLERANCE_M <= x && x <= a1 + LENGTH_TOLERANCE_M;
850    // Fins or a lug on a pod's body tube of no length hang off the airframe on that pod, as
851    // OpenRocket draws winglets: they touch the tube if they span its station, and there is no
852    // length for them to run past.
853    if component.part.is_external() && parent.part.is_body() && parent.length_m == 0.0 {
854        if !holds(fore, aft, parent.fore_station_m) {
855            findings.push(Finding::AttachmentOffBody {
856                component: component.id.clone(),
857                body: parent.id.clone(),
858            });
859        }
860        return;
861    }
862    let span = if component.length_m == 0.0 && matches!(component.part, Part::PodSet(_)) {
863        // A pod set of no length (empty, or a pod of no length) is a point on its tube: it
864        // touches the tube if it sits within the tube's ends.
865        holds(parent.fore_station_m, parent.aft_station_m(), fore).then_some(0.0)
866    } else {
867        excess(fore, aft, parent.fore_station_m, parent.aft_station_m())
868    };
869    if component.part.is_external() {
870        match span {
871            None => findings.push(Finding::AttachmentOffBody {
872                component: component.id.clone(),
873                body: parent.id.clone(),
874            }),
875            // A pod is held by its pylon, not along its length: outboard pods often run past the
876            // tube they hang from.
877            Some(_) if matches!(component.part, Part::PodSet(_)) => {}
878            Some(e) if e > LENGTH_TOLERANCE_M => {
879                findings.push(Finding::AttachmentPastBodyEnd {
880                    component: component.id.clone(),
881                    body: parent.id.clone(),
882                    excess_m: e,
883                });
884            }
885            Some(_) => {}
886        }
887        return;
888    }
889    // An internal part entirely outside its parent runs past it by at least its own length.
890    let e =
891        span.unwrap_or_else(|| (parent.fore_station_m - fore).max(aft - parent.aft_station_m()));
892    if off_rocket {
893        findings.push(Finding::PartOutsideRocket {
894            component: component.id.clone(),
895        });
896    } else if e > LENGTH_TOLERANCE_M {
897        findings.push(Finding::InternalPartPastParentEnd {
898            component: component.id.clone(),
899            parent: parent.id.clone(),
900            excess_m: e,
901        });
902    }
903    let holder = parent.parent.and_then(|i| layout.components.get(i));
904    // A ring shares its parent's axis when the parent is neither a cluster nor off the axis: a
905    // cluster's tubes or an off-axis tube sit beside the ring, not around it.
906    let concentric = parent.part.axis_offset_m() == component.part.axis_offset_m()
907        && !matches!(&parent.part, Part::InnerTube(tube) if !tube.cluster_m.is_empty());
908    // A centering ring whose bore takes its parent tube's outside wraps that tube, as OpenRocket's
909    // pods examples draw the rings on their motor tubes: it sits in whatever surrounds the tube at
910    // the ring's own station, so that is where it needs room.
911    let wraps = concentric
912        && matches!((&component.part, &parent.part), (Part::CenteringRing(ring), Part::InnerTube(tube))
913            if ring.inner_radius_m >= tube.outer_radius_m - LENGTH_TOLERANCE_M);
914    let container = if wraps {
915        wrapped_ring_container(fore, aft, parent, layout).unwrap_or(parent)
916    } else {
917        parent
918    };
919    if let (Some(reach_m), Some(room)) = (
920        component.part.reach_from_m(container.part.axis_offset_m()),
921        room_m(container, fore, aft),
922    ) && reach_m > room.least_m + LENGTH_TOLERANCE_M
923    {
924        let Room { least_m, most_m } = room;
925        let fits = |room_m: f64| reach_m <= room_m + fit_tolerance_m(room_m) + LENGTH_TOLERANCE_M;
926        // A ring at its parent's end face can sit against it, so it needs room in what holds its
927        // parent instead. It covers one end face, not both, and shares its parent's axis.
928        // A ring that wraps its tube is around it, not over its end face, so it is never a cap.
929        let at_end = matches!(component.part, Part::CenteringRing(_))
930            && concentric
931            && !wraps
932            && holds(fore, aft, parent.fore_station_m) != holds(fore, aft, parent.aft_station_m());
933        let cap = holder.filter(|_| at_end).and_then(|holder| {
934            let reach_m = component.part.reach_from_m(holder.part.axis_offset_m())?;
935            let room_m = room_m(holder, fore, aft)?.most_m;
936            (reach_m <= room_m + fit_tolerance_m(room_m) + LENGTH_TOLERANCE_M)
937                .then_some((reach_m, room_m))
938        });
939        // A packed part's width is how it is packed: it sets only the part's own share of the
940        // rocket's inertia, so a packed part drawn too wide still flies as drawn while its
941        // center is in the room. It has no fit tolerance, and centered past the room it is an
942        // error (ADR-170). In a profile the room is the narrowest along the part.
943        let packed_center_inside = component
944            .part
945            .packing()
946            .map(|packing| reach_m - packing.radius_m <= least_m + LENGTH_TOLERANCE_M);
947        let rigid = packed_center_inside.is_none();
948        findings.push(if packed_center_inside == Some(true) {
949            // Room anywhere aft of the part, to the container's end, that is more than the least
950            // along it: the part could fit only farther aft (#367). A NaN room counts.
951            // A part that runs past the container's aft end is measured at that end.
952            let end = container.aft_station_m();
953            let room_widens_aft = room_m(container, aft.min(end), end).is_none_or(|aft_room| {
954                let most_m = aft_room.most_m;
955                most_m.is_nan() || least_m.is_nan() || most_m > least_m + LENGTH_TOLERANCE_M
956            });
957            Finding::PackedPartWiderThanParent {
958                component: component.id.clone(),
959                parent: container.id.clone(),
960                reach_m,
961                room_m: least_m,
962                room_widens_aft,
963            }
964        } else if rigid && fits(least_m) {
965            Finding::InternalPartTightInParent {
966                component: component.id.clone(),
967                parent: container.id.clone(),
968                reach_m,
969                room_m: least_m,
970            }
971        } else if rigid && fits(most_m) {
972            // In a nose cone or transition: it fits where the profile is widest along it, and
973            // runs into the wall where the profile narrows (#313).
974            Finding::InternalPartWedgedInParent {
975                component: component.id.clone(),
976                parent: container.id.clone(),
977                reach_m,
978                room_m: least_m,
979                widest_room_m: most_m,
980            }
981        } else if let Some((reach_m, room_m)) = cap {
982            Finding::RingAgainstParentEnd {
983                ring: component.id.clone(),
984                parent: parent.id.clone(),
985                reach_m,
986                room_m,
987            }
988        } else {
989            Finding::InternalPartWiderThanParent {
990                component: component.id.clone(),
991                parent: container.id.clone(),
992                reach_m,
993                room_m: if rigid { most_m } else { least_m },
994            }
995        });
996    }
997    if let Part::InnerTube(inner) = &component.part {
998        let apart_m = inner
999            .cluster_m
1000            .iter()
1001            .enumerate()
1002            .flat_map(|(i, &[x, y])| {
1003                inner.cluster_m[i + 1..]
1004                    .iter()
1005                    .map(move |&[u, v]| (x - u).hypot(y - v))
1006            })
1007            .fold(f64::INFINITY, f64::min);
1008        let diameter_m = 2.0 * inner.outer_radius_m;
1009        if apart_m < diameter_m - LENGTH_TOLERANCE_M {
1010            findings.push(Finding::ClusterTubesOverlap {
1011                tube: component.id.clone(),
1012                apart_m,
1013                diameter_m,
1014            });
1015        }
1016    }
1017    if let Part::CenteringRing(ring) = &component.part {
1018        // A ring that wraps its tube sits beside the tubes in what surrounds it too.
1019        let around = (container.id != parent.id)
1020            .then(|| layout.find(&container.id))
1021            .flatten()
1022            .map(|(i, _)| i);
1023        for tube in layout.components.iter().filter(|c| {
1024            (c.parent == component.parent || around.is_some_and(|i| c.parent == Some(i)))
1025                && c.id != component.id
1026                && c.id != parent.id
1027        }) {
1028            let Part::InnerTube(inner) = &tube.part else {
1029                continue;
1030            };
1031            // Each tube (every tube of a cluster) covers radii [d − R, d + R] about the ring's
1032            // (the body) axis.
1033            let [x, y] = tube.part.axis_offset_m();
1034            let tubes = if inner.cluster_m.is_empty() {
1035                &[[0.0, 0.0]][..]
1036            } else {
1037                inner.cluster_m.as_slice()
1038            };
1039            let radial = tubes
1040                .iter()
1041                .map(|&[u, v]| {
1042                    let d = (x + u).hypot(y + v);
1043                    (d + inner.outer_radius_m).min(ring.outer_radius_m)
1044                        - (d - inner.outer_radius_m).max(ring.inner_radius_m)
1045                })
1046                .fold(f64::NEG_INFINITY, f64::max);
1047            if overlap(fore, aft, tube.fore_station_m, tube.aft_station_m()) > LENGTH_TOLERANCE_M
1048                && radial > LENGTH_TOLERANCE_M
1049            {
1050                findings.push(Finding::RingOverlapsInnerTube {
1051                    ring: component.id.clone(),
1052                    tube: tube.id.clone(),
1053                });
1054            }
1055        }
1056    }
1057}
1058
1059/// The checks on one configuration's motors in `layout`. The configuration need not be one of the
1060/// rocket's own, so a candidate motor can be checked before it is stored.
1061///
1062/// # Errors
1063///
1064/// As [`Layout::place_motors`].
1065pub fn check_configuration(
1066    layout: &Layout,
1067    configuration: &Configuration,
1068) -> Result<Vec<Finding>, DesignError> {
1069    let mut findings = Vec::new();
1070    // Every tube of a cluster holds the same motor the same way along the axis, so its first tube
1071    // speaks for the mount and each finding is made once.
1072    let placed = layout.place_motors(configuration)?;
1073    for motor in placed.into_iter().filter(|motor| motor.tube == 0) {
1074        let Some((_, mount)) = layout.find(&motor.mount) else {
1075            continue;
1076        };
1077        if let Some(inner) = mount.part.inner_radius_m() {
1078            let inner_diameter = 2.0 * inner;
1079            let motor_diameter_m = motor.mounted.diameter_m;
1080            let slack_m = motor_fit_slack_m(motor_diameter_m);
1081            if motor_diameter_m > inner_diameter + slack_m + LENGTH_TOLERANCE_M {
1082                findings.push(Finding::MotorWiderThanMount {
1083                    configuration: configuration.id.clone(),
1084                    mount: mount.id.clone(),
1085                    motor_diameter_m,
1086                    mount_inner_diameter_m: inner_diameter,
1087                });
1088            } else if motor_diameter_m > inner_diameter + LENGTH_TOLERANCE_M {
1089                findings.push(Finding::MotorTightInMount {
1090                    configuration: configuration.id.clone(),
1091                    mount: mount.id.clone(),
1092                    motor_diameter_m,
1093                    mount_inner_diameter_m: inner_diameter,
1094                });
1095            }
1096        }
1097        let (fore, nozzle) = (motor.fore_station_m(), motor.nozzle_station_m());
1098        if overlap(fore, nozzle, mount.fore_station_m, mount.aft_station_m()) <= 0.0 {
1099            findings.push(Finding::MotorOutsideMount {
1100                configuration: configuration.id.clone(),
1101                mount: mount.id.clone(),
1102            });
1103            continue;
1104        }
1105        let past = mount.fore_station_m - fore;
1106        if past > LENGTH_TOLERANCE_M {
1107            findings.push(Finding::MotorPastMountTop {
1108                configuration: configuration.id.clone(),
1109                mount: mount.id.clone(),
1110                excess_m: past,
1111            });
1112        }
1113    }
1114    Ok(findings)
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use crate::testing::{
1121        attached, body, bottom, inner_tube, mass_component, ring, rocket, stage, three_fin_rocket,
1122        top, tube,
1123    };
1124    use crate::{AutoDimension, MotorMount};
1125    use crate::{Part, Position};
1126
1127    /// Every finding's sentence names its parts by `name`, never by id, and gives its sizes in
1128    /// millimeters.
1129    #[test]
1130    fn describe_names_parts_and_sizes_in_millimeters() {
1131        let id = |s: &str| s.to_owned();
1132        let findings = [
1133            Finding::MotorWiderThanMount {
1134                configuration: id("cfg"),
1135                mount: id("mt"),
1136                motor_diameter_m: 0.038,
1137                mount_inner_diameter_m: 0.029,
1138            },
1139            Finding::MotorTightInMount {
1140                configuration: id("cfg"),
1141                mount: id("mt"),
1142                motor_diameter_m: 0.029,
1143                mount_inner_diameter_m: 0.028_956,
1144            },
1145            Finding::MotorOutsideMount {
1146                configuration: id("cfg"),
1147                mount: id("mt"),
1148            },
1149            Finding::MotorPastMountTop {
1150                configuration: id("cfg"),
1151                mount: id("mt"),
1152                excess_m: 0.012,
1153            },
1154            Finding::AttachmentOffBody {
1155                component: id("fin"),
1156                body: id("bt"),
1157            },
1158            Finding::AttachmentPastBodyEnd {
1159                component: id("fin"),
1160                body: id("bt"),
1161                excess_m: 0.0004,
1162            },
1163            Finding::PartOutsideRocket {
1164                component: id("mass"),
1165            },
1166            Finding::InternalPartPastParentEnd {
1167                component: id("cord"),
1168                parent: id("bt"),
1169                excess_m: 0.050_800_000_000_000_01,
1170            },
1171            Finding::InternalPartWiderThanParent {
1172                component: id("ring"),
1173                parent: id("bt"),
1174                reach_m: 0.04,
1175                room_m: 0.035,
1176            },
1177            Finding::InternalPartTightInParent {
1178                component: id("ring"),
1179                parent: id("bt"),
1180                reach_m: 0.035_05,
1181                room_m: 0.035,
1182            },
1183            Finding::PackedPartWiderThanParent {
1184                component: id("payload"),
1185                parent: id("bt"),
1186                reach_m: 0.0125,
1187                room_m: 0.0105,
1188                room_widens_aft: false,
1189            },
1190            Finding::RingAgainstParentEnd {
1191                ring: id("ring"),
1192                parent: id("cp"),
1193                reach_m: 0.04,
1194                room_m: 0.0399,
1195            },
1196            Finding::ClusterTubesOverlap {
1197                tube: id("it"),
1198                apart_m: 0.02,
1199                diameter_m: 0.025,
1200            },
1201            Finding::RingOverlapsInnerTube {
1202                ring: id("ring"),
1203                tube: id("it"),
1204            },
1205            Finding::CenterOutsideRocket {
1206                stage: id("st"),
1207                station_m: -0.25,
1208            },
1209            Finding::RadiusStep {
1210                fore: id("nose"),
1211                aft: id("bt"),
1212                fore_radius_m: 0.0205,
1213                aft_radius_m: 0.0207,
1214            },
1215            Finding::NoNoseCone {
1216                component: id("bt"),
1217            },
1218            Finding::InternalPartWedgedInParent {
1219                component: id("coupler"),
1220                parent: id("nose"),
1221                reach_m: 0.0245,
1222                room_m: 0.0115,
1223                widest_room_m: 0.025,
1224            },
1225        ];
1226        let name = |id: &str| format!("<{id}>");
1227        let sentences: Vec<String> = findings.iter().map(|f| f.describe(&name)).collect();
1228        for (finding, sentence) in findings.iter().zip(&sentences) {
1229            // Every id the finding carries is named, as `name` gives it.
1230            let value = serde_json::to_value(finding).unwrap();
1231            for (key, field) in value.as_object().unwrap() {
1232                if let Some(text) = field.as_str().filter(|_| key != "kind") {
1233                    assert!(sentence.contains(&format!("<{text}>")), "{key}: {sentence}");
1234                }
1235            }
1236            assert!(
1237                !sentence.contains('{') && !sentence.contains('_'),
1238                "{sentence}"
1239            );
1240        }
1241        assert_eq!(
1242            sentences[0],
1243            "the motor in <mt> is 38.0 mm wide, wider than the mount's 29.0 mm bore by more \
1244             than a real case's slack (configuration <cfg>)"
1245        );
1246        assert!(sentences[5].contains(" 0.40 mm past "), "{}", sentences[5]);
1247        assert!(sentences[7].contains(" 50.8 mm past "), "{}", sentences[7]);
1248        assert!(sentences[14].contains(" 250.0 mm forward of the nose tip"));
1249        assert_eq!(mm(0.000_05), "0.050 mm");
1250        // Two sizes a sentence compares never print alike.
1251        assert!(
1252            sentences[1].contains(" is nominally 29.00 mm wide in a 28.96 mm bore"),
1253            "{}",
1254            sentences[1]
1255        );
1256        assert!(
1257            sentences[9].contains(" reaches 35.05 mm from the axis of <bt>, which has 35.00 mm")
1258        );
1259        assert!(sentences[10].contains(
1260            "<payload> is packed to reach 12.5 mm from the axis of <bt>, which has 10.5 mm of room"
1261        ));
1262        assert!(
1263            sentences[17].contains(
1264                "<coupler> reaches 24.5 mm from the axis of <nose>, which narrows to 11.5 mm of \
1265                 room along it (25.0 mm where widest)"
1266            ),
1267            "{}",
1268            sentences[17]
1269        );
1270        assert!(sentences[11].contains(
1271            " reaches 40.0 mm from the axis, wider than <cp>, and caps its end, in 39.9 mm"
1272        ));
1273        assert!(sentences[15].contains(" ends 20.5 mm in radius where <bt> begins 20.7 mm"));
1274        assert_eq!(
1275            mm_pair(0.038, 0.037_97),
1276            ("38.00 mm".to_owned(), "37.97 mm".to_owned())
1277        );
1278        assert_eq!(
1279            mm_pair(0.040, 0.000_049),
1280            ("40.000 mm".to_owned(), "0.049 mm".to_owned())
1281        );
1282        assert_eq!(
1283            mm_pair(0.025, 0.024_98),
1284            ("25.00 mm".to_owned(), "24.98 mm".to_owned())
1285        );
1286        assert_eq!(
1287            mm_pair(0.020_5, 0.020_500_01),
1288            ("20.5000 mm".to_owned(), "20.5000 mm".to_owned())
1289        );
1290        assert_eq!(mm(0.0005), "0.50 mm");
1291        assert_eq!(mm(0.0), "0.0 mm");
1292    }
1293
1294    fn kinds(findings: &[Finding]) -> Vec<(String, Severity)> {
1295        findings
1296            .iter()
1297            .map(|f| {
1298                let tag = serde_json::to_value(f).unwrap()["kind"]
1299                    .as_str()
1300                    .unwrap()
1301                    .to_owned();
1302                (tag, f.severity())
1303            })
1304            .collect()
1305    }
1306
1307    #[test]
1308    fn sample_rocket_passes_every_check() {
1309        let findings = check(&three_fin_rocket()).unwrap();
1310        assert!(findings.is_empty(), "{findings:?}");
1311    }
1312
1313    /// Loft lesson L50: a 54 mm motor in a 38 mm mount flew, and flew high. Here it is an error, a
1314    /// motor exactly as wide as the bore is not, and a case longer than the mount only warns.
1315    #[test]
1316    fn motor_wider_than_mount_is_rejected() {
1317        let mut design = three_fin_rocket();
1318        design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.054, 0.2);
1319        let findings = check(&design).unwrap();
1320        assert_eq!(
1321            findings,
1322            vec![Finding::MotorWiderThanMount {
1323                configuration: "main".to_owned(),
1324                mount: "mmt".to_owned(),
1325                motor_diameter_m: 0.054,
1326                mount_inner_diameter_m: 2.0 * (0.020 - 0.001),
1327            }]
1328        );
1329        assert_eq!(findings[0].severity(), Severity::Error);
1330        assert!(has_errors(&findings));
1331        // A cluster of three such tubes is still one finding: the motor is named once.
1332        if let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part {
1333            tube.cluster_m = vec![[0.0, 0.0], [0.04, 0.0], [-0.04, 0.0]];
1334        }
1335        let findings = check(&design).unwrap();
1336        let wider = findings
1337            .iter()
1338            .filter(|f| matches!(f, Finding::MotorWiderThanMount { .. }))
1339            .count();
1340        assert_eq!(wider, 1, "{findings:?}");
1341
1342        // The sample's 38 mm motor fills its 38 mm bore exactly: no finding.
1343        let exact = three_fin_rocket();
1344        assert!(check(&exact).unwrap().is_empty());
1345        // Wider by more than round-off is an error again.
1346        let mut design = three_fin_rocket();
1347        design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.038 + 1e-6, 0.2);
1348        assert!(has_errors(&check(&design).unwrap()));
1349
1350        // A 0.35 m case in a 0.3 m mount with 0.01 m of overhang reaches 0.04 m past its top.
1351        let mut design = three_fin_rocket();
1352        design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.038, 0.35);
1353        let findings = check(&design).unwrap();
1354        let [Finding::MotorPastMountTop { excess_m, .. }] = findings[..] else {
1355            panic!("{findings:?}")
1356        };
1357        assert!((excess_m - 0.04).abs() < 1e-12);
1358        assert!(!has_errors(&findings));
1359    }
1360
1361    /// Issue #280: a nominal 29 mm motor in LOC's 1.140 in (28.956 mm) tube only warns, as
1362    /// AeroTech's 29 mm case is at most 1.130 in (28.702 mm) across; a hair past that, and the
1363    /// Loft demo's 28.0 mm bore, are errors. Sizes whose cases are as wide as their names get no
1364    /// slack, and neither does a diameter that isn't a nominal size.
1365    #[test]
1366    fn a_nominal_motor_in_its_matching_tube_only_warns() {
1367        let fitted = |motor_m: f64, bore_m: f64| {
1368            let mut design = three_fin_rocket();
1369            let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part else {
1370                panic!("the tests' rocket's mount is an inner tube");
1371            };
1372            tube.outer_radius_m = bore_m / 2.0 + tube.thickness_m;
1373            design.configurations[0].motors[0] = crate::testing::motor("mmt", motor_m, 0.2);
1374            check(&design).unwrap()
1375        };
1376        let findings = fitted(0.029, 0.028956);
1377        let [
1378            Finding::MotorTightInMount {
1379                motor_diameter_m,
1380                mount_inner_diameter_m,
1381                ..
1382            },
1383        ] = findings[..]
1384        else {
1385            panic!("{findings:?}")
1386        };
1387        assert_eq!(motor_diameter_m, 0.029);
1388        assert!((mount_inner_diameter_m - 0.028956).abs() < 1e-12);
1389        assert_eq!(findings[0].severity(), Severity::Warning);
1390        assert!(!has_errors(&findings));
1391        // The slack is the name less the largest case: 0.298, 0.0478 and 0.1438 mm.
1392        assert!((motor_fit_slack_m(0.029) - 0.000_298).abs() < 1e-12);
1393        assert!((motor_fit_slack_m(0.024) - 0.000_047_8).abs() < 1e-12);
1394        assert!((motor_fit_slack_m(0.018) - 0.000_143_8).abs() < 1e-12);
1395        assert_eq!(motor_fit_slack_m(0.038), 0.0);
1396        assert!(!has_errors(&fitted(0.029, 0.028702 + 1e-7)));
1397        for (motor_m, bore_m) in [
1398            (0.029, 0.028702 - 1e-6),
1399            (0.029, 0.028),
1400            (0.024, 0.023952 - 1e-6),
1401            (0.038, 0.038 - 1e-6),
1402            (0.054, 0.054 - 1e-6),
1403            // Not a nominal size: compared as it is.
1404            (0.0289, 0.0289 - 1e-6),
1405        ] {
1406            // A 54 mm mount is wider than the tests' airframe, so only the motor's findings count.
1407            let findings: Vec<_> = fitted(motor_m, bore_m)
1408                .into_iter()
1409                .filter(|f| {
1410                    matches!(
1411                        f,
1412                        Finding::MotorWiderThanMount { .. } | Finding::MotorTightInMount { .. }
1413                    )
1414                })
1415                .collect();
1416            assert!(
1417                matches!(&findings[..], [Finding::MotorWiderThanMount { .. }]),
1418                "{motor_m} in {bore_m}: {findings:?}"
1419            );
1420        }
1421    }
1422
1423    /// A pod of no length (fins or a lug on a pod's tube of length 0, as OpenRocket draws
1424    /// winglets) and an empty pod set raise no finding where they sit on their tube, though they
1425    /// have no length to overlap it with; a pod set of no length beyond the tube's end is still
1426    /// off it (M1.13b2).
1427    #[test]
1428    fn a_pod_set_of_no_length_touches_its_tube_where_it_sits() {
1429        let podded = |children: Vec<crate::Component>, position: Position| {
1430            let mut design = three_fin_rocket();
1431            let mut pods = attached(
1432                "pods",
1433                Part::PodSet(crate::parts::PodSet {
1434                    count: 2,
1435                    radial_offset_m: 0.06,
1436                    angle_rad: 0.0,
1437                }),
1438                position,
1439            );
1440            pods.children = children;
1441            design.stages[0].components[1].children.push(pods);
1442            check(&design).unwrap()
1443        };
1444        let mut phantom = body("phantom", tube(0.0, 0.0, 0.0));
1445        phantom.children = vec![attached(
1446            "wings",
1447            crate::testing::fins(0.05, 0.03),
1448            bottom(0.0),
1449        )];
1450        let base = check(&three_fin_rocket()).unwrap();
1451        assert_eq!(podded(vec![phantom.clone()], top(0.1)), base);
1452        assert_eq!(podded(Vec::new(), top(0.1)), base);
1453        // At the tube's aft end, and just past it.
1454        assert_eq!(podded(Vec::new(), bottom(0.0)), base);
1455        let off = podded(vec![phantom], bottom(0.01));
1456        assert!(
1457            off.contains(&Finding::AttachmentOffBody {
1458                component: "pods".to_owned(),
1459                body: "airframe".to_owned(),
1460            }),
1461            "{off:?}"
1462        );
1463        assert_eq!(off.len(), base.len() + 1, "{off:?}");
1464
1465        // Fins that don't reach their tube of no length are off it, as any fins are.
1466        let mut far = body("phantom", tube(0.0, 0.0, 0.0));
1467        far.children = vec![attached(
1468            "wings",
1469            crate::testing::fins(0.05, 0.03),
1470            top(0.01),
1471        )];
1472        let off = podded(vec![far], top(0.1));
1473        assert_eq!(
1474            off.iter()
1475                .filter(
1476                    |f| matches!(f, Finding::AttachmentOffBody { component, body }
1477                    if component == "wings" && body == "phantom")
1478                )
1479                .count(),
1480            1,
1481            "{off:?}"
1482        );
1483        assert_eq!(off.len(), base.len() + 1, "{off:?}");
1484    }
1485
1486    /// Loft lesson L50: fins could sit off the airframe. A fin root that touches none of its body
1487    /// tube is an error; one that runs past the tube's end warns.
1488    #[test]
1489    fn fin_root_must_touch_body() {
1490        let with_fins = |offset: f64| {
1491            let mut design = three_fin_rocket();
1492            design.stages[0].components[1].children[3].position = Some(bottom(offset));
1493            check(&design).unwrap()
1494        };
1495        // Root chord 0.1 m; the tube ends at station 1.0.
1496        let off = with_fins(0.15);
1497        assert_eq!(
1498            off,
1499            vec![Finding::AttachmentOffBody {
1500                component: "fins".to_owned(),
1501                body: "airframe".to_owned(),
1502            }]
1503        );
1504        assert!(has_errors(&off));
1505        // Touching only at the tube's end is still off the body.
1506        assert!(has_errors(&with_fins(0.1)));
1507        let past = with_fins(0.04);
1508        let [
1509            Finding::AttachmentPastBodyEnd {
1510                ref component,
1511                excess_m,
1512                ..
1513            },
1514        ] = past[..]
1515        else {
1516            panic!("{past:?}")
1517        };
1518        assert_eq!(component, "fins");
1519        assert!((excess_m - 0.04).abs() < 1e-12);
1520        assert!(!has_errors(&past));
1521        assert!(with_fins(0.0).is_empty());
1522        // Forward of the tube too.
1523        let mut design = three_fin_rocket();
1524        design.stages[0].components[1].children[3].position = Some(top(-0.2));
1525        assert!(has_errors(&check(&design).unwrap()));
1526    }
1527
1528    #[test]
1529    fn internal_parts_radius_steps_and_a_missing_nose_are_found() {
1530        let mut airframe = body("airframe", tube(0.5, 0.03, 0.001));
1531        airframe.children = vec![
1532            // 1 mm wider than the 29 mm bore, 0.5 mm past its fit tolerance.
1533            attached("wide-ring", ring(0.005, 0.03, 0.01), top(0.1)),
1534            // Hanging 0.05 m out of the tube's aft end.
1535            attached("long-mmt", inner_tube(0.2, 0.01, 0.001), bottom(0.05)),
1536            // Entirely forward of the tube.
1537            attached("lost", mass_component(0.1, 0.05, 0.01), top(-0.2)),
1538            // 0.5 mm wider than the bore: a tight fit.
1539            attached("tight-ring", ring(0.005, 0.0295, 0.01), top(0.2)),
1540        ];
1541        let mut mount = airframe.children[1].clone();
1542        mount.motor_mount = Some(MotorMount::default());
1543        airframe.children[1] = mount;
1544        let design = rocket(vec![stage(
1545            "s",
1546            vec![airframe, body("tail", tube(0.1, 0.02, 0.001))],
1547        )]);
1548        let findings = check(&design).unwrap();
1549        assert_eq!(
1550            kinds(&findings),
1551            vec![
1552                ("no_nose_cone".to_owned(), Severity::Warning),
1553                ("radius_step".to_owned(), Severity::Warning),
1554                (
1555                    "internal_part_wider_than_parent".to_owned(),
1556                    Severity::Error
1557                ),
1558                (
1559                    "internal_part_past_parent_end".to_owned(),
1560                    Severity::Warning
1561                ),
1562                // Wholly forward of the nose tip: one error, not also a warning.
1563                ("part_outside_rocket".to_owned(), Severity::Error),
1564                (
1565                    "internal_part_tight_in_parent".to_owned(),
1566                    Severity::Warning
1567                ),
1568            ]
1569        );
1570
1571        // A ring left solid across the motor mount counts their mass twice.
1572        let mut design = three_fin_rocket();
1573        design.stages[0].components[1].children[1].auto = vec![AutoDimension::OuterRadius];
1574        assert_eq!(
1575            check(&design).unwrap(),
1576            vec![Finding::RingOverlapsInnerTube {
1577                ring: "ring-fore".to_owned(),
1578                tube: "mmt".to_owned(),
1579            }]
1580        );
1581    }
1582
1583    /// Radial room is measured about the parent's own axis: a block centered in an off-axis pod
1584    /// fits, and a mass on the body axis attached to the pod doesn't. Rings in a cluster warn.
1585    #[test]
1586    fn internal_parts_are_measured_from_their_parents_axis() {
1587        let offset = |part: Part, r: f64| match part {
1588            Part::InnerTube(mut t) => {
1589                t.radial_offset_m = r;
1590                Part::InnerTube(t)
1591            }
1592            Part::MassComponent(mut m) => {
1593                m.packing.radial_offset_m = r;
1594                Part::MassComponent(m)
1595            }
1596            other => other,
1597        };
1598        let mut pod = attached(
1599            "pod",
1600            offset(inner_tube(0.3, 0.02, 0.001), 0.025),
1601            bottom(0.0),
1602        );
1603        pod.children = vec![attached(
1604            "block",
1605            offset(inner_tube(0.01, 0.019, 0.003), 0.025),
1606            top(0.0),
1607        )];
1608        let mut airframe = body("airframe", tube(0.8, 0.05, 0.002));
1609        airframe.children = vec![pod, attached("ring", ring(0.005, 0.048, 0.0), bottom(-0.1))];
1610        let design = rocket(vec![stage(
1611            "s",
1612            vec![body("nose", crate::testing::nose(0.2, 0.05)), airframe],
1613        )]);
1614        assert_eq!(
1615            check(&design).unwrap(),
1616            vec![Finding::RingOverlapsInnerTube {
1617                ring: "ring".to_owned(),
1618                tube: "pod".to_owned(),
1619            }]
1620        );
1621
1622        let mut design = design;
1623        design.stages[0].components[1].children[0].children[0] =
1624            attached("on-axis", mass_component(0.1, 0.05, 0.004), top(0.1));
1625        let findings = check(&design).unwrap();
1626        let wide = findings
1627            .iter()
1628            .find_map(|f| match f {
1629                Finding::InternalPartWiderThanParent {
1630                    component,
1631                    reach_m,
1632                    room_m,
1633                    ..
1634                } if component == "on-axis" => Some((*reach_m, *room_m)),
1635                _ => None,
1636            })
1637            .unwrap();
1638        assert!(
1639            (wide.0 - 0.029).abs() < 1e-15 && (wide.1 - 0.019).abs() < 1e-15,
1640            "{wide:?}"
1641        );
1642    }
1643
1644    /// A packed part drawn wider than its parent's bore warns, by any amount, while its center is
1645    /// in the bore: OpenRocket's *Deployable payload* packs a 25 mm payload into a 21 mm bore. Its
1646    /// width sets only its own inertia. A ring as wide is an error, and so is a packed part whose
1647    /// center is past the bore (ADR-170).
1648    #[test]
1649    fn a_packed_part_wider_than_its_bore_warns_while_its_center_is_inside() {
1650        // The airframe's bore is 0.0255 m; its fit tolerance (51 mm) is 0.8 mm.
1651        let room = 0.0255;
1652        let placed = |part: Part, offset_m: f64| {
1653            let part = match part {
1654                Part::MassComponent(mut m) => {
1655                    m.packing.radial_offset_m = offset_m;
1656                    Part::MassComponent(m)
1657                }
1658                Part::Parachute(mut p) => {
1659                    p.packing.radial_offset_m = offset_m;
1660                    Part::Parachute(p)
1661                }
1662                other => other,
1663            };
1664            let mut design = three_fin_rocket();
1665            design.stages[0].components[1]
1666                .children
1667                .push(attached("packed", part, top(0.2)));
1668            check(&design).unwrap()
1669        };
1670        let packed = |reach_m: f64| {
1671            vec![Finding::PackedPartWiderThanParent {
1672                component: "packed".to_owned(),
1673                parent: "airframe".to_owned(),
1674                reach_m,
1675                room_m: room,
1676                // A tube's bore is as wide aft of it: the part's center of gravity is no less
1677                // possible where it is drawn (#367).
1678                room_widens_aft: false,
1679            }]
1680        };
1681        // 5 mm too wide, or within the fit tolerance: a warning, not a fit to sand.
1682        for radius_m in [0.0305, 0.026] {
1683            assert_eq!(
1684                placed(mass_component(0.1, 0.05, radius_m), 0.0),
1685                packed(radius_m)
1686            );
1687        }
1688        let mut chute = three_fin_rocket().stages[0].components[1].children[4]
1689            .part
1690            .clone();
1691        if let Part::Parachute(p) = &mut chute {
1692            p.packing.radius_m = 0.03;
1693        }
1694        assert_eq!(placed(chute, 0.0), packed(0.03));
1695        // Its center on the bore's edge still warns; 0.1 µm past it, the part is an error.
1696        let reach = room + 0.002;
1697        assert_eq!(
1698            placed(mass_component(0.1, 0.05, 0.002), room),
1699            packed(reach)
1700        );
1701        assert_eq!(
1702            placed(mass_component(0.1, 0.05, 0.002), room + 1e-7),
1703            vec![Finding::InternalPartWiderThanParent {
1704                component: "packed".to_owned(),
1705                parent: "airframe".to_owned(),
1706                reach_m: room + 1e-7 + 0.002,
1707                room_m: room,
1708            }]
1709        );
1710        // A thin part centered in the wall, reaching past the bore by less than the fit
1711        // tolerance, is no fit to sand: packed parts have no tolerance.
1712        for (radius_m, offset_m) in [(0.0002, room + 0.0002), (0.0001, room + 0.0005)] {
1713            assert_eq!(
1714                placed(mass_component(0.1, 0.05, radius_m), offset_m),
1715                vec![Finding::InternalPartWiderThanParent {
1716                    component: "packed".to_owned(),
1717                    parent: "airframe".to_owned(),
1718                    reach_m: offset_m + radius_m,
1719                    room_m: room,
1720                }]
1721            );
1722        }
1723        // A centering ring as wide is not packed: an error.
1724        let findings = placed(ring(0.005, 0.0305, 0.0), 0.0);
1725        assert!(
1726            matches!(&findings[..], [Finding::InternalPartWiderThanParent { component, .. }]
1727                if component == "packed"),
1728            "{findings:?}"
1729        );
1730    }
1731
1732    /// A part drawn wider than its parent's bore by up to the coarse general tolerance of the
1733    /// bore's diameter (ISO 2768-1 class c) is a fit and warns; 0.1 µm past that is an error. A
1734    /// bulkhead sized to the airframe's bore at a coupler's end, as OpenRocket's two-stage example
1735    /// draws its own, is a cap and warns; the same bulkhead inside the coupler, or one too wide
1736    /// for the airframe too, is an error.
1737    #[test]
1738    fn a_part_wider_than_its_parent_warns_only_as_a_fit_or_a_cap() {
1739        // The airframe: 0.027 m outside, 0.0255 m bore; the coupler's bore is 0.0245 m, a
1740        // diameter of 49 mm, whose tolerance is 0.8 mm.
1741        let fitted = |radius_m: f64, at: Position| {
1742            let mut design = three_fin_rocket();
1743            let mut coupler = attached("coupler", inner_tube(0.1, 0.0255, 0.001), top(0.1));
1744            coupler.children = vec![attached("bulkhead", ring(0.005, radius_m, 0.0), at)];
1745            design.stages[0].components[1].children.push(coupler);
1746            check(&design).unwrap()
1747        };
1748        let room = 0.0255 - 0.001;
1749        let tight = room + 0.0008;
1750        assert_eq!(
1751            fitted(tight, top(0.02)),
1752            vec![Finding::InternalPartTightInParent {
1753                component: "bulkhead".to_owned(),
1754                parent: "coupler".to_owned(),
1755                reach_m: tight,
1756                room_m: room,
1757            }]
1758        );
1759        assert_eq!(
1760            fitted(tight + 1e-7, top(0.02)),
1761            vec![Finding::InternalPartWiderThanParent {
1762                component: "bulkhead".to_owned(),
1763                parent: "coupler".to_owned(),
1764                reach_m: tight + 1e-7,
1765                room_m: room,
1766            }]
1767        );
1768        // At either end face, inside or outside the coupler, a bulkhead as wide as the airframe's
1769        // bore, or wider by its tolerance (0.8 mm for 51 mm), is a cap.
1770        let capped = 0.0255 + 0.0008;
1771        for (radius_m, at) in [
1772            (0.0255, top(0.0)),
1773            (0.0255, top(-0.005)),
1774            (0.0255, bottom(0.0)),
1775            (capped, bottom(0.005)),
1776        ] {
1777            let findings = fitted(radius_m, at);
1778            assert!(!has_errors(&findings));
1779            // One forward of the coupler's top also runs past its end, which only warns.
1780            let findings: Vec<_> = findings
1781                .into_iter()
1782                .filter(|f| !matches!(f, Finding::InternalPartPastParentEnd { .. }))
1783                .collect();
1784            assert_eq!(
1785                findings,
1786                vec![Finding::RingAgainstParentEnd {
1787                    ring: "bulkhead".to_owned(),
1788                    parent: "coupler".to_owned(),
1789                    reach_m: radius_m,
1790                    room_m: 0.0255,
1791                }],
1792                "{radius_m} at {at:?}"
1793            );
1794        }
1795        // Inside the coupler, away from its ends, or too wide for the airframe: an error.
1796        for (radius_m, at) in [(0.0255, top(0.02)), (capped + 1e-7, top(0.0))] {
1797            let findings = fitted(radius_m, at);
1798            assert!(
1799                matches!(&findings[..], [Finding::InternalPartWiderThanParent { component, .. }]
1800                    if component == "bulkhead"),
1801                "{radius_m} at {at:?}: {findings:?}"
1802            );
1803        }
1804        // A ring at the end of a clustered or off-axis tube sits beside it, not around it, and a
1805        // ring as long as its parent covers both ends: neither is a cap.
1806        for (cluster, offset_m, length_m) in [
1807            (vec![[0.018, 0.0], [-0.018, 0.0]], 0.0, 0.005),
1808            (Vec::new(), 0.018, 0.005),
1809            (Vec::new(), 0.0, 0.1),
1810        ] {
1811            let mut design = three_fin_rocket();
1812            let mut tube = inner_tube(0.1, 0.007, 0.001);
1813            if let Part::InnerTube(inner) = &mut tube {
1814                inner.cluster_m = cluster.clone();
1815                inner.radial_offset_m = offset_m;
1816            }
1817            let mut holder = attached("holder", tube, top(0.1));
1818            holder.children = vec![attached("ring", ring(length_m, 0.012, 0.0), top(0.0))];
1819            design.stages[0].components[1].children.push(holder);
1820            let findings = check(&design).unwrap();
1821            assert!(
1822                findings.iter().any(|f| matches!(f,
1823                    Finding::InternalPartWiderThanParent { component, .. } if component == "ring")),
1824                "{cluster:?} {offset_m} {length_m}: {findings:?}"
1825            );
1826        }
1827        // A tube is no cap, even at its parent's end.
1828        let mut design = three_fin_rocket();
1829        let mut coupler = attached("coupler", inner_tube(0.1, 0.0255, 0.001), top(0.1));
1830        coupler.children = vec![attached(
1831            "sleeve",
1832            inner_tube(0.01, 0.0255, 0.001),
1833            top(0.0),
1834        )];
1835        design.stages[0].components[1].children.push(coupler);
1836        assert!(matches!(
1837            &check(&design).unwrap()[..],
1838            [Finding::InternalPartWiderThanParent { component, .. }] if component == "sleeve"
1839        ));
1840    }
1841
1842    /// A centering ring drawn as a child of the motor tube it wraps, as OpenRocket's pods examples
1843    /// draw theirs, needs room in the airframe around the tube, not in the tube's bore: it fits,
1844    /// warns as a fit or errs by the airframe's room. A ring whose bore is 1 µm short of the
1845    /// tube's outside, or whose tube is off the axis or a cluster, is still measured in the tube.
1846    #[test]
1847    fn a_ring_around_its_motor_tube_needs_room_in_the_airframe() {
1848        // The airframe's bore is 0.0255 m (a 51 mm diameter, tolerance 0.8 mm); the motor tube is
1849        // 0.020 m outside with a 0.019 m bore.
1850        let wrapped = |outer_m: f64, inner_m: f64, offset_m: f64, cluster: Vec<[f64; 2]>| {
1851            let mut design = three_fin_rocket();
1852            let mount = &mut design.stages[0].components[1].children[0];
1853            if let Part::InnerTube(tube) = &mut mount.part {
1854                tube.radial_offset_m = offset_m;
1855                tube.cluster_m = cluster;
1856            }
1857            mount.children = vec![attached("wrap", ring(0.003, outer_m, inner_m), top(0.1))];
1858            check(&design).unwrap()
1859        };
1860        let (outer, bore, airframe) = (0.020, 0.019, 0.0255);
1861        assert_eq!(wrapped(airframe, outer, 0.0, Vec::new()), vec![]);
1862        // A bore wider than the tube still sits on it.
1863        assert_eq!(wrapped(airframe, outer + 0.001, 0.0, Vec::new()), vec![]);
1864        let tight = airframe + 0.0008;
1865        assert_eq!(
1866            wrapped(tight, outer, 0.0, Vec::new()),
1867            vec![Finding::InternalPartTightInParent {
1868                component: "wrap".to_owned(),
1869                parent: "airframe".to_owned(),
1870                reach_m: tight,
1871                room_m: airframe,
1872            }]
1873        );
1874        assert_eq!(
1875            wrapped(tight + 1e-7, outer, 0.0, Vec::new()),
1876            vec![Finding::InternalPartWiderThanParent {
1877                component: "wrap".to_owned(),
1878                parent: "airframe".to_owned(),
1879                reach_m: tight + 1e-7,
1880                room_m: airframe,
1881            }]
1882        );
1883        // Not wrapping the tube: inside it, so measured against its bore, an error.
1884        let in_tube = |findings: Vec<Finding>| {
1885            findings.iter().any(|f| {
1886                matches!(f, Finding::InternalPartWiderThanParent { component, parent, room_m, .. }
1887                    if component == "wrap" && parent == "mmt" && *room_m == bore)
1888            })
1889        };
1890        assert!(in_tube(wrapped(airframe, outer - 1e-6, 0.0, Vec::new())));
1891        assert!(in_tube(wrapped(airframe, outer, 0.002, Vec::new())));
1892        assert!(in_tube(wrapped(
1893            airframe,
1894            outer,
1895            0.0,
1896            vec![[0.0, 0.0], [0.05, 0.0]]
1897        )));
1898        // The tube's outside less the round-off tolerance still wraps.
1899        assert_eq!(wrapped(airframe, outer - 5e-10, 0.0, Vec::new()), vec![]);
1900
1901        // With the motor tube 50 mm out of the airframe's aft end, a ring wholly aft of the
1902        // airframe, or across its end, has nothing around it that holds it: it is measured in
1903        // the tube, an error, and no cap at the tube's end.
1904        let overhung = |at: Position| {
1905            let mut design = three_fin_rocket();
1906            let mount = &mut design.stages[0].components[1].children[0];
1907            mount.position = Some(bottom(0.05));
1908            mount.children = vec![attached("wrap", ring(0.003, airframe, outer), at)];
1909            check(&design).unwrap()
1910        };
1911        assert!(in_tube(overhung(bottom(0.0))));
1912        assert!(in_tube(overhung(bottom(-0.048))));
1913        assert_eq!(
1914            overhung(top(0.1))
1915                .into_iter()
1916                .filter(
1917                    |f| matches!(f, Finding::InternalPartWiderThanParent { component, .. }
1918                    | Finding::InternalPartTightInParent { component, .. } if component == "wrap")
1919                )
1920                .count(),
1921            0
1922        );
1923
1924        // A tube beside the wrapped one in the airframe, crossing the ring, overlaps it.
1925        let mut design = three_fin_rocket();
1926        let airframe_part = &mut design.stages[0].components[1];
1927        airframe_part.children[0].children =
1928            vec![attached("wrap", ring(0.003, airframe, outer), top(0.1))];
1929        let mut side = inner_tube(0.04, 0.004, 0.0005);
1930        if let Part::InnerTube(tube) = &mut side {
1931            tube.radial_offset_m = 0.021;
1932        }
1933        airframe_part
1934            .children
1935            .push(attached("side", side, top(0.58)));
1936        assert_eq!(
1937            check(&design).unwrap(),
1938            vec![Finding::RingOverlapsInnerTube {
1939                ring: "wrap".to_owned(),
1940                tube: "side".to_owned(),
1941            }]
1942        );
1943    }
1944
1945    /// ISO 2768-1's coarse class by the room's diameter, each band up to and including its upper
1946    /// size; none below 0.5 mm or for a room that isn't a number.
1947    #[test]
1948    fn the_fit_tolerance_is_the_coarse_general_tolerance_of_the_bore() {
1949        for (diameter_m, tolerance_m) in [
1950            (0.0004, 0.0),
1951            (0.0005, 0.0002),
1952            (0.002, 0.0002),
1953            (0.006, 0.0003),
1954            (0.0061, 0.0005),
1955            (0.030, 0.0005),
1956            (0.0301, 0.0008),
1957            (0.120, 0.0008),
1958            (0.150, 0.0012),
1959            (5.0, 0.004),
1960        ] {
1961            assert_eq!(
1962                fit_tolerance_m(diameter_m / 2.0),
1963                tolerance_m,
1964                "{diameter_m}"
1965            );
1966        }
1967        assert_eq!(fit_tolerance_m(f64::NAN), 0.0);
1968    }
1969
1970    /// A motor that isn't in its mount at all is an error, whichever way it missed.
1971    #[test]
1972    fn motor_outside_its_mount_is_rejected() {
1973        for overhang in [5.0, 0.2, -0.3] {
1974            let mut design = three_fin_rocket();
1975            design.stages[0].components[1].children[0].motor_mount = Some(MotorMount {
1976                overhang_m: overhang,
1977            });
1978            let findings = check(&design).unwrap();
1979            assert_eq!(
1980                findings,
1981                vec![Finding::MotorOutsideMount {
1982                    configuration: "main".to_owned(),
1983                    mount: "mmt".to_owned(),
1984                }],
1985                "{overhang}"
1986            );
1987            assert!(has_errors(&findings));
1988        }
1989    }
1990
1991    /// Parts wholly off the rocket are errors, and a stage center moved off it by an override too.
1992    #[test]
1993    fn parts_and_centers_off_the_rocket_are_rejected() {
1994        let mut design = three_fin_rocket();
1995        design.stages[0].components[1].children[4].position = Some(top(2.0));
1996        assert_eq!(
1997            check(&design).unwrap(),
1998            vec![Finding::PartOutsideRocket {
1999                component: "chute".to_owned(),
2000            }]
2001        );
2002        let mut design = three_fin_rocket();
2003        design.stages[0].overrides.cg_aft_m = Some(350.0);
2004        let findings = check(&design).unwrap();
2005        assert!(
2006            matches!(&findings[..], [Finding::CenterOutsideRocket { stage, station_m }]
2007                if stage == "sustainer" && *station_m == 350.0),
2008            "{findings:?}"
2009        );
2010        // A retainer on a motor mount sticking out past the airframe is on the rocket.
2011        let mut design = three_fin_rocket();
2012        let mount = &mut design.stages[0].components[1].children[0];
2013        mount.position = Some(bottom(0.012));
2014        mount.children = vec![attached(
2015            "retainer",
2016            mass_component(0.05, 0.01, 0.015),
2017            bottom(0.0),
2018        )];
2019        let findings = check(&design).unwrap();
2020        assert!(
2021            matches!(&findings[..], [Finding::InternalPartPastParentEnd { component, .. }]
2022                if component == "mmt"),
2023            "{findings:?}"
2024        );
2025        // So is one flush against the mount's aft end, wholly behind the airframe.
2026        let mut flush = design.clone();
2027        flush.stages[0].components[1].children[0].children[0].position = Some(top(0.3));
2028        let layout = flush.layout().unwrap();
2029        let (_, retainer) = layout.find("retainer").unwrap();
2030        assert!(
2031            retainer.fore_station_m >= layout.length_m,
2032            "the test needs it behind"
2033        );
2034        let findings = check(&flush).unwrap();
2035        assert!(
2036            findings
2037                .iter()
2038                .all(|f| !matches!(f, Finding::PartOutsideRocket { .. })),
2039            "{findings:?}"
2040        );
2041        assert!(!has_errors(&findings), "{findings:?}");
2042        // But one clear of the mount is off the rocket.
2043        let mut clear = design.clone();
2044        clear.stages[0].components[1].children[0].children[0].position = Some(top(0.35));
2045        assert_eq!(
2046            check(&clear).unwrap(),
2047            vec![
2048                Finding::InternalPartPastParentEnd {
2049                    component: "mmt".to_owned(),
2050                    parent: "airframe".to_owned(),
2051                    excess_m: check(&design)
2052                        .unwrap()
2053                        .iter()
2054                        .find_map(|f| match f {
2055                            Finding::InternalPartPastParentEnd { excess_m, .. } => Some(*excess_m),
2056                            _ => None,
2057                        })
2058                        .unwrap(),
2059                },
2060                Finding::PartOutsideRocket {
2061                    component: "retainer".to_owned(),
2062                },
2063            ]
2064        );
2065        // Fins trailing far past a light stage's end move its center off the rocket, but with no
2066        // override that is geometry, not a typo.
2067        let mut can = body("can", tube(0.3, 0.03, 0.0005));
2068        let mut fins = attached("trailing-fins", crate::testing::fins(0.2, 0.1), bottom(0.0));
2069        if let Part::FinSet(set) = &mut fins.part {
2070            set.planform = crate::FinPlanform::Trapezoidal {
2071                root_chord_m: 0.2,
2072                tip_chord_m: 0.2,
2073                span_m: 0.1,
2074                sweep_m: 0.4,
2075            };
2076        }
2077        can.children = vec![fins];
2078        let design = rocket(vec![stage("s", vec![can])]);
2079        let layout = design.layout().unwrap();
2080        assert!(
2081            -layout.stages[0].mass.cg_m.z > layout.length_m,
2082            "the test needs it off"
2083        );
2084        assert!(!has_errors(&check(&design).unwrap()));
2085        // A mass override can't move a center past its parts, so it doesn't arm the check.
2086        let mut heavier = design.clone();
2087        heavier.stages[0].components[0].children[0]
2088            .overrides
2089            .mass_kg = Some(1.0);
2090        assert!(!has_errors(&check(&heavier).unwrap()));
2091        // Nor can a sideways center override.
2092        let mut sideways = design.clone();
2093        sideways.stages[0].components[0].children[0]
2094            .overrides
2095            .cg_xy_m = Some([0.0, 0.0]);
2096        assert!(!has_errors(&check(&sideways).unwrap()));
2097        // A component's axial center override arms it.
2098        let mut moved = design.clone();
2099        moved.stages[0].components[0].children[0].overrides.cg_aft_m = Some(50.0);
2100        let findings = check(&moved).unwrap();
2101        assert!(
2102            matches!(&findings[..], [Finding::NoNoseCone { .. }, Finding::CenterOutsideRocket { stage, .. }] if stage == "s"),
2103            "{findings:?}"
2104        );
2105        // A point mass at the rocket's end is on it, whatever the round-off in the length
2106        // (0.1 + 0.7 is 0.7999999999999999).
2107        let mut design = rocket(vec![stage(
2108            "s",
2109            vec![
2110                body("nose", crate::testing::nose(0.1, 0.03)),
2111                body("airframe", tube(0.7, 0.03, 0.001)),
2112            ],
2113        )]);
2114        design.stages[0].components[1].children = vec![attached(
2115            "tail-weight",
2116            mass_component(0.05, 0.0, 0.01),
2117            Position::Absolute { station_m: 0.8 },
2118        )];
2119        assert!(design.layout().unwrap().length_m < 0.8);
2120        assert!(check(&design).unwrap().is_empty());
2121        // And a part flush behind the rocket's end is on it, however its length adds up
2122        // (0.1 + 0.2 is 0.30000000000000004, 0.15 + 0.15 is 0.3).
2123        for (nose, tube_length) in [(0.1, 0.2), (0.15, 0.15)] {
2124            let mut design = rocket(vec![stage(
2125                "s",
2126                vec![
2127                    body("nose", crate::testing::nose(nose, 0.03)),
2128                    body("airframe", tube(tube_length, 0.03, 0.001)),
2129                ],
2130            )]);
2131            design.stages[0].components[1].children = vec![attached(
2132                "tail-block",
2133                mass_component(0.05, 0.05, 0.01),
2134                Position::Absolute { station_m: 0.3 },
2135            )];
2136            let findings = check(&design).unwrap();
2137            assert!(
2138                matches!(&findings[..], [Finding::InternalPartPastParentEnd { component, .. }]
2139                    if component == "tail-block"),
2140                "{nose} + {tube_length}: {findings:?}"
2141            );
2142        }
2143        // Ballast wider than a nose cone, measured against the cone's inside where it sits: packed,
2144        // so a warning while its center is in the cone (ADR-170).
2145        let mut design = three_fin_rocket();
2146        design.stages[0].components[0].children = vec![attached(
2147            "ballast",
2148            mass_component(0.1, 0.02, 0.2),
2149            bottom(0.0),
2150        )];
2151        assert!(matches!(
2152            &check(&design).unwrap()[..],
2153            [Finding::PackedPartWiderThanParent { component, .. }] if component == "ballast"
2154        ));
2155        // A check of a layout with a bad parent index skips it instead of panicking.
2156        let mut layout = three_fin_rocket().layout().unwrap();
2157        layout.components[2].parent = Some(999);
2158        let _ = check_layout(&layout);
2159    }
2160
2161    /// #367: a packed mass too wide for a transition's room where it is drawn could sit only
2162    /// where the transition is wider. Where that is aft (a shoulder, widening aft) its drawn
2163    /// center of gravity is forward of where it can be, the flattering side, and the warning
2164    /// names #367; where it is forward (a boattail, narrowing aft) it is the safe side, and the
2165    /// warning doesn't.
2166    #[test]
2167    fn a_packed_part_names_367_only_where_the_room_widens_aft_of_it() {
2168        let widens = |fore_radius_m: f64, aft_radius_m: f64, position: Option<Position>| {
2169            let transition = Part::Transition(crate::Transition {
2170                shape: crate::NoseShape::Conical {},
2171                clipped: false,
2172                length_m: 0.2,
2173                fore_radius_m,
2174                aft_radius_m,
2175                wall: crate::Wall::Shell { thickness_m: 0.002 },
2176                fore_shoulder: None,
2177                aft_shoulder: None,
2178                material: crate::Material::bulk("PLA", 1240.0),
2179            });
2180            let mut middle = body("transition", transition);
2181            // A 30 mm long, 20 mm radius mass, 10 mm from the transition's 15 mm end: 13.75 mm
2182            // of room or less along it, 23 mm at the other end.
2183            let position = position.unwrap_or(if fore_radius_m < aft_radius_m {
2184                top(0.01)
2185            } else {
2186                bottom(-0.01)
2187            });
2188            middle.children = vec![attached("mass", mass_component(0.05, 0.03, 0.02), position)];
2189            let design = rocket(vec![stage(
2190                "sustainer",
2191                vec![
2192                    body("nose", crate::testing::nose(0.2, fore_radius_m)),
2193                    middle,
2194                    body("airframe", tube(0.6, aft_radius_m, 0.0015)),
2195                ],
2196            )]);
2197            let findings = check(&design).unwrap();
2198            let packed: Vec<_> = findings
2199                .iter()
2200                .filter_map(|finding| match finding {
2201                    Finding::PackedPartWiderThanParent {
2202                        component,
2203                        room_widens_aft,
2204                        ..
2205                    } if component == "mass" => Some((
2206                        *room_widens_aft,
2207                        finding.describe(&|id: &str| id.to_owned()),
2208                    )),
2209                    _ => None,
2210                })
2211                .collect();
2212            assert_eq!(packed.len(), 1, "{findings:?}");
2213            let (flag, text) = packed[0].clone();
2214            assert_eq!(flag, text.contains("issue #367"), "{text}");
2215            flag
2216        };
2217        assert!(widens(0.015, 0.025, None), "a shoulder widens aft");
2218        assert!(!widens(0.025, 0.015, None), "a boattail narrows aft");
2219        // Run 10 mm past a boattail's aft end, it is measured at that end, not by the room the
2220        // profile gives outside it (found in review).
2221        assert!(
2222            !widens(0.025, 0.015, Some(bottom(0.01))),
2223            "past a boattail's end"
2224        );
2225    }
2226
2227    /// #313: a part in a nose cone is measured against the cone's inside where it sits, not its
2228    /// base: the outer radius less the 2 mm wall over the part's span. The 0.2 m conical nose of
2229    /// 27 mm base radius has `r(x) = 0.135 x` outside, so `0.135 x − 0.002` inside.
2230    #[test]
2231    fn a_part_in_a_nose_cone_needs_room_where_it_sits() {
2232        let findings = |part: Part, position: Position| {
2233            let mut nose = body("nose", crate::testing::nose(0.2, 0.027));
2234            nose.children = vec![attached("part", part, position)];
2235            let design = rocket(vec![stage(
2236                "sustainer",
2237                vec![nose, body("airframe", tube(0.6, 0.027, 0.0015))],
2238            )]);
2239            check(&design).unwrap()
2240        };
2241        let room = |x_m: f64| (0.135 * x_m - 0.002_f64).max(0.0);
2242        // A 20 mm bulkhead at the tip has no room at all there, nor 10 mm aft: an error, measured
2243        // where the cone is widest along it. Before #313 the cone's 27 mm base let it pass.
2244        let bulkhead = || ring(0.01, 0.02, 0.0);
2245        let tip = findings(bulkhead(), top(0.0));
2246        assert!(
2247            matches!(&tip[..], [Finding::InternalPartWiderThanParent { component, parent, reach_m, room_m }]
2248                if component == "part" && parent == "nose" && *reach_m == 0.02 && *room_m == 0.0),
2249            "{tip:?}"
2250        );
2251        // At the base it fits: 23.65 mm of room at its fore face.
2252        assert_eq!(findings(bulkhead(), bottom(0.0)), vec![]);
2253        // So does a 20 mm mass there.
2254        assert_eq!(
2255            findings(mass_component(0.05, 0.01, 0.02), bottom(0.0)),
2256            vec![]
2257        );
2258        // A 20 mm mass at the tip is packed: a warning while its center is in the room
2259        // (ADR-170), here on the axis where the wall closes in.
2260        let packed = findings(mass_component(0.05, 0.03, 0.02), top(0.0));
2261        assert!(
2262            matches!(&packed[..], [Finding::PackedPartWiderThanParent { component, room_m, room_widens_aft, .. }]
2263                if component == "part" && *room_m == 0.0 && *room_widens_aft),
2264            "{packed:?}"
2265        );
2266        // The cone widens aft of it, so its mass can sit only farther aft: the margin may read
2267        // high, and the warning names #367.
2268        let text = packed[0].describe(&|id: &str| id.to_owned());
2269        assert!(text.contains("issue #367"), "{text}");
2270        assert!(text.contains("margin may read high"), "{text}");
2271        // Off the axis by 5 mm its center is out of that room: an error.
2272        let mut off_axis = mass_component(0.05, 0.03, 0.02);
2273        if let Part::MassComponent(mass) = &mut off_axis {
2274            mass.packing.radial_offset_m = 0.005;
2275        }
2276        let off = findings(off_axis, top(0.0));
2277        assert!(
2278            matches!(&off[..], [Finding::InternalPartWiderThanParent { component, reach_m, room_m, .. }]
2279                if component == "part" && (*reach_m - 0.025).abs() < 1e-15 && *room_m == 0.0),
2280            "{off:?}"
2281        );
2282        // A mass of no packed length at the tip is in the cone, where it has no room: a warning
2283        // centered, an error off the axis (found in review: it once counted as past the tip).
2284        let point = findings(mass_component(0.05, 0.0, 0.02), top(0.0));
2285        assert!(
2286            matches!(&point[..], [Finding::PackedPartWiderThanParent { room_m, room_widens_aft, .. }]
2287                if *room_m == 0.0 && *room_widens_aft),
2288            "{point:?}"
2289        );
2290        let mut point_off_axis = mass_component(0.05, 0.0, 0.02);
2291        if let Part::MassComponent(mass) = &mut point_off_axis {
2292            mass.packing.radial_offset_m = 0.005;
2293        }
2294        let point_off = findings(point_off_axis, top(0.0));
2295        assert!(
2296            matches!(&point_off[..], [Finding::InternalPartWiderThanParent { room_m, .. }] if *room_m == 0.0),
2297            "{point_off:?}"
2298        );
2299        // A 24.5 mm tube over the cone's aft 100 mm fits at the base (25 mm of room) and runs into
2300        // the wall forward, where the cone has 11.5 mm: wedged, a warning.
2301        let wedged = findings(inner_tube(0.1, 0.0245, 0.001), bottom(0.0));
2302        match &wedged[..] {
2303            [
2304                Finding::InternalPartWedgedInParent {
2305                    component,
2306                    parent,
2307                    reach_m,
2308                    room_m,
2309                    widest_room_m,
2310                },
2311            ] => {
2312                assert_eq!((component.as_str(), parent.as_str()), ("part", "nose"));
2313                assert_eq!(*reach_m, 0.0245);
2314                assert!((room_m - room(0.1)).abs() < 1e-15, "{room_m}");
2315                assert!((widest_room_m - room(0.2)).abs() < 1e-15, "{widest_room_m}");
2316            }
2317            other => panic!("{other:?}"),
2318        }
2319        // Past fit tolerance even at the base: an error, on the widest room.
2320        let wide = findings(inner_tube(0.1, 0.0265, 0.001), bottom(0.0));
2321        assert!(
2322            matches!(&wide[..], [Finding::InternalPartWiderThanParent { room_m, .. }]
2323                if (room_m - room(0.2)).abs() < 1e-15),
2324            "{wide:?}"
2325        );
2326        // Wholly aft of the base, touching it, a ring is measured as before #313, against the
2327        // cone's largest radius: it is past the cone's end, and that is the finding.
2328        let past = findings(ring(0.006, 0.026, 0.0), bottom(0.006));
2329        assert!(
2330            matches!(&past[..], [Finding::InternalPartPastParentEnd { component, .. }]
2331                if component == "part"),
2332            "{past:?}"
2333        );
2334    }
2335
2336    /// A ring of three 40 mm tubes touches at `40 mm / √3` from the axis and crosses inside it.
2337    #[test]
2338    fn a_cluster_whose_tubes_cross_is_flagged() {
2339        let at = |r: f64| {
2340            let mut design = three_fin_rocket();
2341            if let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part {
2342                tube.cluster_m = [90.0_f64, 210.0, 330.0]
2343                    .iter()
2344                    .map(|a| [r * a.to_radians().cos(), r * a.to_radians().sin()])
2345                    .collect();
2346            }
2347            check(&design)
2348                .unwrap()
2349                .into_iter()
2350                .filter(|f| matches!(f, Finding::ClusterTubesOverlap { .. }))
2351                .collect::<Vec<_>>()
2352        };
2353        assert!(at(0.04 / 3.0_f64.sqrt()).is_empty());
2354        let [
2355            Finding::ClusterTubesOverlap {
2356                tube,
2357                apart_m,
2358                diameter_m,
2359            },
2360        ] = &at(0.02)[..]
2361        else {
2362            panic!("one finding");
2363        };
2364        assert_eq!(tube, "mmt");
2365        assert!((apart_m - 0.02 * 3.0_f64.sqrt()).abs() < 1e-15, "{apart_m}");
2366        assert_eq!(*diameter_m, 0.04);
2367        assert_eq!(at(0.02)[0].severity(), Severity::Warning);
2368    }
2369
2370    /// A finding written before the move to US spelling, `centre_outside_rocket`, reads as the same
2371    /// finding.
2372    #[test]
2373    fn a_finding_with_the_old_uk_kind_reads_the_same() {
2374        let finding = Finding::CenterOutsideRocket {
2375            stage: "sustainer".to_owned(),
2376            station_m: 2.5,
2377        };
2378        let text = serde_json::to_string(&finding).unwrap();
2379        assert!(
2380            text.contains("\"kind\":\"center_outside_rocket\""),
2381            "{text}"
2382        );
2383        let old = text.replace("center_outside_rocket", "centre_outside_rocket");
2384        assert_eq!(serde_json::from_str::<Finding>(&old).unwrap(), finding);
2385    }
2386}