Skip to main content

hpr_io/ork/
attached.rs

1//! The parts on and inside the body: what hangs off a `.ork` design's spine.
2//!
3//! [`super::component`] reads the spine: the stages and the body components stacked in them.
4//! Everything else in a design hangs off that trunk, and this module reads it: the tubes, couplers,
5//! rings and bulkheads **inside** a body component, the fins, tube fins, launch lugs and rail
6//! buttons **on** it, and the mass objects, parachutes, streamers and shock cords **packed** in it.
7//!
8//! Three things run through all of them.
9//!
10//! - **Where a part sits** is an offset along its parent and the end it is measured from, which is
11//!   [`Position`]. OpenRocket writes the end under `method` on the newer tag name and `type` on the
12//!   older one, with the same five words.
13//! - **What a part takes from its parent** is an automatic dimension: a coupler's outer radius is
14//!   the tube's bore, a ring's is too, a ring's own bore is the motor tube inside it, and a packed
15//!   part fills whatever room is left. None of that is worked out here:
16//!   [`hpr_design::Rocket::layout`] does it, once, for every reader ([Loft lesson L60][lessons]:
17//!   Loft resolved these as it walked, so the answer depended on the order the siblings were
18//!   written in, and a bulkhead inside a coupler came out as `NaN`).
19//! - **Angles are degrees in the file and radians in `hpr-design`**, which is easy to miss because
20//!   nothing in the file says so: read as radians, `<angleoffset>180</angleoffset>` is more than
21//!   twenty-eight turns instead of half of one. Of the 188 angles in the reference corpus that are
22//!   not zero, 178 are larger than 2π, so they cannot be radians.
23//!
24//! A part this reader cannot give `hpr-design` an honest shape for is **left out with a warning**
25//! rather than guessed at, so the rest of the design still opens. The guide lists every such rule:
26//! [OpenRocket `.ork` design files][guide].
27//!
28//! [guide]: https://nrdptel.github.io/hpr-sim/format/ork.html
29//! [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
30
31use hpr_design::fins::{FinCrossSection, FinFillet, FinPlanform, FinSet, FinTab, TubeFinSet};
32use hpr_design::parts::{
33    CenteringRing, InnerTube, LaunchLug, MassComponent, Packing, Parachute, PodSet, RailButton,
34    ShockCord, Streamer,
35};
36use hpr_design::tree::{AutoDimension, Component, Overrides, ParallelStage, Part, Position, Stage};
37use hpr_design::{Finish, MotorMount, Wall};
38
39use super::component::{
40    BODY_TAGS, Ids, UNNAMED_RAIL_BUTTON, body, material, material_or, overrides, stated_radius,
41    subcomponents,
42};
43use super::document::Element;
44use super::motors;
45use super::recovery;
46use super::value::{AXIAL_OFFSET, INSTANCE_COUNT, OVERRIDE_FLAGS, Values};
47use super::warning::{Warning, WarningKind};
48
49/// The tags that hang off the spine and hold another part inside them.
50const TUBES: [&str; 3] = ["innertube", "tubecoupler", "engineblock"];
51
52/// Every tag this module knows how to read. A tag outside it belongs to a later milestone, and is
53/// tallied by the caller rather than warned about one by one.
54///
55/// A slice, not an array: this list grows every time a milestone reads another kind of part, and
56/// its length should not be part of the public API.
57pub const ATTACHED_TAGS: &[&str] = &[
58    "innertube",
59    "tubecoupler",
60    "engineblock",
61    "centeringring",
62    "bulkhead",
63    "trapezoidfinset",
64    "ellipticalfinset",
65    "freeformfinset",
66    "tubefinset",
67    "launchlug",
68    "railbutton",
69    "masscomponent",
70    "parachute",
71    "streamer",
72    "shockcord",
73    "podset",
74    "parallelstage",
75];
76
77/// How many of one part a design may say there are.
78///
79/// `hpr-design` builds one body per instance and adds them up, so a count is an allocation: a file
80/// saying `<fincount>4000000000</fincount>` would ask for hundreds of gigabytes, and running out
81/// of memory is an abort rather than an error. The reader is the boundary with a file it did not
82/// write, so the bound lives here, beside the unpacking and nesting limits the container and the
83/// document already carry. The most instanced part in the reference library is a set of 8 fins.
84const MOST_INSTANCES: u32 = 64;
85
86/// How many parts are nested inside `element`, at any depth. A part left out takes them with it,
87/// and a warning that does not say so is a mass quietly missing.
88fn nested(element: &Element) -> usize {
89    subcomponents(element).map(|child| 1 + nested(child)).sum()
90}
91
92/// `", and the 2 parts inside it"`, or nothing when there are none.
93fn and_what_was_inside(element: &Element) -> String {
94    match nested(element) {
95        0 => String::new(),
96        1 => ", and the one part inside it".to_owned(),
97        many => format!(", and the {many} parts inside it"),
98    }
99}
100
101/// A part kind's name as a reader would say it: `inner tube`, not `inner_tube`.
102fn spoken(part: &Part) -> String {
103    part.kind_name().replace('_', " ")
104}
105
106/// Reads everything in `element`'s `<subcomponents>` that belongs to a body component or a tube.
107///
108/// `parent` is what the parent was read as, which decides two things a child cannot decide for
109/// itself: whether an external part may attach here at all, and whether an automatic radius has a
110/// bore to take. `parent_auto` is which of its dimensions were automatic, which a pod set's
111/// distance from the axis needs to know ([`pod_set`]). Anything left out is counted into
112/// `skipped` or warned about, never dropped in silence.
113pub(super) fn children(
114    element: &Element,
115    parent: &Part,
116    parent_auto: &[AutoDimension],
117    at: &str,
118    ids: &mut Ids,
119    skipped: &mut Vec<String>,
120    warnings: &mut Vec<Warning>,
121) -> Vec<Component> {
122    let mut components = Vec::new();
123    for (index, child) in subcomponents(element).enumerate() {
124        let at = format!("{at}/{}[{index}]", child.name);
125        let read = if child.name == "podset" {
126            pod_set(child, parent, parent_auto, &at, ids, skipped, warnings)
127        } else if child.name == "parallelstage" {
128            // A stage of its own, beside this tube: the stage reader takes it from `ids`.
129            if let Some(stage) =
130                parallel_stage(child, parent, parent_auto, &at, ids, skipped, warnings)
131            {
132                ids.parallel.push(stage);
133            }
134            continue;
135        } else {
136            one(child, parent, parent_auto, &at, ids, skipped, warnings)
137        };
138        match read {
139            Some(component) => components.push(component),
140            // A part this module knows and could not read has already said so, with its reason;
141            // only a tag no milestone reads yet goes into the tally.
142            None => {
143                if !ATTACHED_TAGS.contains(&child.name.as_str()) {
144                    skipped.push(child.name.clone());
145                }
146            }
147        }
148    }
149    components
150}
151
152/// Reads one attached part, or leaves it out.
153fn one(
154    element: &Element,
155    parent: &Part,
156    parent_auto: &[AutoDimension],
157    at: &str,
158    ids: &mut Ids,
159    skipped: &mut Vec<String>,
160    warnings: &mut Vec<Warning>,
161) -> Option<Component> {
162    let mut auto = Vec::new();
163    let mut values = Values::new(element, at, warnings);
164    let part = match element.name.as_str() {
165        tag if TUBES.contains(&tag) => inner_tube(tag, &mut values, &mut auto),
166        "centeringring" => ring(&mut values, &mut auto, true),
167        "bulkhead" => ring(&mut values, &mut auto, false),
168        "trapezoidfinset" | "ellipticalfinset" | "freeformfinset" => fin_set(element, &mut values),
169        "tubefinset" => tube_fins(&mut values, &mut auto),
170        "launchlug" => launch_lug(&mut values),
171        "railbutton" => rail_button(&mut values),
172        "masscomponent" => mass_component(&mut values, &mut auto),
173        "parachute" => parachute(&mut values, &mut auto),
174        "streamer" => streamer(&mut values, &mut auto),
175        "shockcord" => shock_cord(&mut values, &mut auto),
176        // A pod set is read by `pod_set` and a parallel stage by `parallel_stage`, before this;
177        // anything else is a tag this reader has never seen, counted by the caller.
178        _ => return None,
179    }?;
180
181    // `hpr-design` attaches fins, tube fins, lugs and rail buttons to a body tube, and a fin set to
182    // a nose cone or a transition too, its root along the surface ([`follow_the_body`]).
183    // OpenRocket lets the others sit on a nose cone or a transition, where their root is not a
184    // straight line; reading one onto a tube would put it on a body it does not have.
185    let on_a_surface = matches!(part, Part::FinSet(_))
186        && matches!(parent, Part::NoseCone(_) | Part::Transition(_));
187    if part.is_external() && !matches!(parent, Part::BodyTube(_)) && !on_a_surface {
188        values.warn_at(
189            WarningKind::Skipped,
190            format!(
191                "a {} sits on a {}, and hpr attaches one only to a body tube; it was left out{}",
192                spoken(&part),
193                spoken(parent),
194                and_what_was_inside(element)
195            ),
196        );
197        return None;
198    }
199    // An automatic outer or packed radius is the parent's bore. A hollow nose cone's or
200    // transition's bore narrows along it, and the layout takes an outer radius at the part's
201    // narrower end, as OpenRocket 24.12 does (ADR-096); a packed radius in one, or anything
202    // automatic in a solid one or any other part, has no bore to take. The layout would refuse the
203    // whole design over it, so the part goes instead. A part in a hollow nose whose bore narrows
204    // to nothing at its end (at the tip) stays, and the layout refuses the design: OpenRocket
205    // weighs it as nothing, which hpr does not yet do (ADR-096). A ring's automatic *bore* is not in this
206    // list: it comes from the ring's siblings, and is zero when none of them is a motor tube, so
207    // it needs nothing of the parent.
208    let hollow = |wall: &Wall| matches!(wall, Wall::Shell { .. });
209    let bore_along = match parent {
210        Part::BodyTube(_) | Part::InnerTube(_) => true,
211        Part::NoseCone(nose) => hollow(&nose.wall),
212        Part::Transition(transition) => hollow(&transition.wall),
213        _ => false,
214    };
215    // A tube fin set's automatic radius comes from the body it rings, not a bore; it sits on a body
216    // tube, whose `bore_along` is true, or was left out above.
217    let needs_a_bore = auto.iter().any(|dimension| match dimension {
218        AutoDimension::OuterRadius => !bore_along,
219        AutoDimension::PackedRadius => !matches!(parent, Part::BodyTube(_) | Part::InnerTube(_)),
220        _ => false,
221    });
222    if needs_a_bore {
223        values.warn_at(
224            WarningKind::Skipped,
225            format!(
226                "the {} takes an automatic radius from its parent's bore, and a {} has none; it \
227                 was left out{}",
228                spoken(&part),
229                spoken(parent),
230                and_what_was_inside(element)
231            ),
232        );
233        return None;
234    }
235
236    // OpenRocket places a part inside an inner tube from that tube's axis; `hpr-design` measures
237    // every part's offset from the body's axis, and this reader does not compose the two yet
238    // (#181). Nothing in the reference library does this, so it is said out loud.
239    let [x, y] = parent.axis_offset_m();
240    if !part.is_external() && (x != 0.0 || y != 0.0) {
241        values.warn_at(
242            WarningKind::Unusual,
243            format!(
244                "the {} sits inside a {} {} m off the body's axis: OpenRocket places it from that \
245                 tube's axis, and hpr from the body's axis (issue #181){}",
246                spoken(&part),
247                spoken(parent),
248                x.hypot(y),
249                match nested(element) {
250                    0 => String::new(),
251                    1 => "; so is the one part inside it".to_owned(),
252                    many => format!("; so are the {many} parts inside it"),
253                }
254            ),
255        );
256    }
257
258    let name = values.word(&["name"]).unwrap_or_default();
259    let position = match &part {
260        Part::RailButton(button) => centered_on_its_position(position(&mut values), button),
261        _ => position(&mut values),
262    };
263    let part = match follow_the_body(part, parent, parent_auto, position) {
264        Ok(part) => part,
265        Err(why) => {
266            values.warn_at(WarningKind::Skipped, why);
267            return None;
268        }
269    };
270    let finish = finish(&mut values);
271    let (overrides, include_children, drag_override) = overrides(&mut values);
272    radial_offset_on_the_surface(&mut values, &part);
273    // Only a tube holds other parts. `hpr-design` says the same, so anything written inside
274    // another kind is said out loud here rather than tallied with the parallel stages, whose tally
275    // carries a message about a spine of their own. Nothing in the reference library does this.
276    let children = if matches!(part, Part::InnerTube(_)) {
277        children(element, &part, &auto, at, ids, skipped, warnings)
278    } else {
279        if subcomponents(element).next().is_some() {
280            let (kind, inside) = (spoken(&part), and_what_was_inside(element));
281            values.warn_at(
282                WarningKind::Skipped,
283                format!("a {kind} holds no parts in hpr; it was read without{inside}"),
284            );
285        }
286        Vec::new()
287    };
288    let mount = match part {
289        Part::InnerTube(_) => motors::mount(element, at, warnings),
290        _ => None,
291    };
292    let device = match part {
293        Part::Parachute(_) | Part::Streamer(_) => Some(recovery::device(element, at, warnings)),
294        _ => None,
295    };
296    let id = ids.take(
297        &mut Values::new(element, at, warnings),
298        &element.name.clone(),
299    );
300    let motor_mount = mount.map(|mount| {
301        let spec = MotorMount {
302            overhang_m: mount.overhang_m,
303        };
304        ids.mounts.push((id.clone(), mount));
305        spec
306    });
307    if let Some(device) = device {
308        ids.devices.push((id.clone(), device));
309    }
310    ids.read.insert(at.to_owned());
311    Some(Component {
312        id,
313        name,
314        part,
315        position: Some(position),
316        auto,
317        motor_mount,
318        finish,
319        overrides,
320        overrides_include_children: include_children,
321        drag_override,
322        children,
323    })
324}
325
326/// Reads one pod set (ADR-089, ADR-090), or leaves it out with a warning.
327///
328/// A pod set holds pods beside its body tube, each the stack of nose cones, body tubes and
329/// transitions written inside it, with everything on and in them; `hpr-design`'s
330/// [`PodSet`] repeats that stack `instancecount` times around the axis. Its roll angle is the
331/// `angleoffset` read as every angle is ([`roll_angle`]); the angle's `method` changes nothing for
332/// a pod set on a body tube, which sits on the axis.
333///
334/// **The distance from the axis** is not in any document. OpenRocket 24.12 was asked, as an
335/// external oracle, on probes (`validation/oracles/openrocket/pods.py`, recorded in
336/// `validation/fixtures/ork/openrocket-pods.json`), and puts a pod's axis at
337///
338/// | `radiusoffset` method | distance of each pod's axis from the body's |
339/// | --- | --- |
340/// | `relative` | `R + ρ + v` |
341/// | `surface` | `R + ρ`, the number ignored |
342/// | `free` | `v` |
343///
344/// for the tube's outer radius `R`, the written number `v`, and `ρ` the **widest** radius of the
345/// pod's own body components: a probe whose widest tube is aft of a stated narrower nose and tube,
346/// and ahead of another narrower tube, puts the pod at the widest radius, which neither the first
347/// part, the first tube nor the last part gives. An automatic radius in a pod can only take a
348/// radius stated in the pod, so `ρ` is the widest stated one, and a pod with none is left out.
349/// `hpr-design` holds a pod set at a fixed distance, so when the tube's radius is automatic, the
350/// number OpenRocket cached for it is taken, and said; with none cached, the pod set is left out.
351fn pod_set(
352    element: &Element,
353    parent: &Part,
354    parent_auto: &[AutoDimension],
355    at: &str,
356    ids: &mut Ids,
357    skipped: &mut Vec<String>,
358    warnings: &mut Vec<Warning>,
359) -> Option<Component> {
360    let mut values = Values::new(element, at, warnings);
361    let Part::BodyTube(tube) = parent else {
362        values.warn_at(
363            WarningKind::Skipped,
364            format!(
365                "a pod set hangs from a {}, and hpr hangs one only from a body tube; it was left \
366                 out{}",
367                spoken(parent),
368                and_what_was_inside(element)
369            ),
370        );
371        return None;
372    };
373    // The path names every pod set and parallel stage above this one, and this one: one inside a
374    // pod would multiply its copies by the outer set's, and `hpr-design` refuses it.
375    if at.matches("/podset[").count() + at.matches("/parallelstage[").count() > 1 {
376        values.warn_at(
377            WarningKind::Skipped,
378            format!(
379                "a pod set inside a pod, which hpr does not nest; it was left out{}",
380                and_what_was_inside(element)
381            ),
382        );
383        return None;
384    }
385    let count = instances(&mut values, "pod set")?;
386    let name = values.word(&["name"]).unwrap_or_default();
387    let position = position(&mut values);
388    let (mut overrides, include_children, mut drag_override) = overrides(&mut values);
389    let angle_rad = roll_angle(&mut values);
390    let method = values
391        .element(&["radiusoffset"])
392        .map(|offset| offset.attribute("method").unwrap_or("relative").to_owned());
393    let number = values.number(&["radiusoffset"]);
394    let id = ids.take(&mut Values::new(element, at, warnings), &element.name);
395
396    let mut pods = Vec::new();
397    for (index, child) in subcomponents(element).enumerate() {
398        let at = format!("{at}/{}[{index}]", child.name);
399        if BODY_TAGS.contains(&child.name.as_str()) {
400            pods.push(body(child, &at, ids, skipped, warnings).0);
401        } else {
402            let tag = child.name.clone();
403            Values::new(child, &at, warnings).warn_at(
404                WarningKind::Skipped,
405                format!(
406                    "a `{tag}` directly inside a pod set, where hpr's pod is a stack of nose \
407                     cones, body tubes and transitions; it was left out{}",
408                    and_what_was_inside(child)
409                ),
410            );
411        }
412    }
413    let mut values = Values::new(element, at, warnings);
414    // OpenRocket draws a pod of a tube with no length, radius or wall (its "phantom body") to hang
415    // fins or a lug off the axis, and `hpr-design` weighs such a tube as nothing. A nose cone or a
416    // transition of no length, or a part of negative length, is no shape `hpr-design` has, so
417    // that pod set goes rather than the whole design.
418    if pods.iter().any(|pod| {
419        let length_m = pod.part.length_m();
420        length_m < 0.0 || (length_m == 0.0 && !matches!(pod.part, Part::BodyTube(_)))
421    }) {
422        values.warn_at(
423            WarningKind::Skipped,
424            format!(
425                "a pod with a nose cone or transition of no length, or a part of negative length, \
426                 which hpr cannot lay out; the pod set was left out{}",
427                and_what_was_inside(element)
428            ),
429        );
430        return None;
431    }
432    // A tube of no length has no room inside it.
433    let flat = |pod: &Component| pod.part.length_m() == 0.0;
434    if pods
435        .iter()
436        .any(|pod| flat(pod) && pod.children.iter().any(|c| !c.part.is_external()))
437    {
438        values.warn_at(
439            WarningKind::Skipped,
440            format!(
441                "a pod's tube of no length with a part inside it, where it has no room; the pod set \
442                 was left out{}",
443                and_what_was_inside(element)
444            ),
445        );
446        return None;
447    }
448    // The widest stated radius of the pod: an automatic one can only take a stated one in the pod.
449    let mut widest: Option<f64> = None;
450    for pod in &pods {
451        let radii: &[(f64, AutoDimension)] = match &pod.part {
452            Part::NoseCone(nose) => &[(nose.base_radius_m, AutoDimension::BaseRadius)],
453            Part::BodyTube(tube) => &[(tube.outer_radius_m, AutoDimension::OuterRadius)],
454            Part::Transition(t) => &[
455                (t.fore_radius_m, AutoDimension::ForeRadius),
456                (t.aft_radius_m, AutoDimension::AftRadius),
457            ],
458            _ => &[],
459        };
460        for &(radius_m, dimension) in radii {
461            if !pod.auto.contains(&dimension) {
462                widest = Some(widest.map_or(radius_m, |w: f64| w.max(radius_m)));
463            }
464        }
465    }
466    // A pod set that holds nothing weighs nothing, and its radius is no matter.
467    let Some(pod_radius_m) = widest.or(pods.is_empty().then_some(0.0)) else {
468        values.warn_at(
469            WarningKind::Skipped,
470            format!(
471                "a pod whose radii are all automatic, with no fixed radius in the pod to take; \
472                 the pod set was left out{}",
473                and_what_was_inside(element)
474            ),
475        );
476        return None;
477    };
478    let tube_radius_m = tube.outer_radius_m;
479    let method = method.unwrap_or_else(|| {
480        values.warn_at(
481            WarningKind::Unusual,
482            "no `radiusoffset`, so the pods were read touching the tube",
483        );
484        "surface".to_owned()
485    });
486    // `relative` and `surface` with no number are the pods touching the tube; `free` with none is
487    // no place at all.
488    let Some(number) = number.or((method != "free").then_some(0.0)) else {
489        values.warn_at(
490            WarningKind::Skipped,
491            format!(
492                "a `free` `radiusoffset` with no number, so where the pods sit is not known; the \
493                 pod set was left out{}",
494                and_what_was_inside(element)
495            ),
496        );
497        return None;
498    };
499    let radial_offset_m = match method.as_str() {
500        "relative" => tube_radius_m + pod_radius_m + number,
501        "surface" => tube_radius_m + pod_radius_m,
502        "free" => number,
503        other => {
504            values.warn_at(
505                WarningKind::Unusual,
506                format!(
507                    "a `radiusoffset` measured by `{other}`, which this reader does not know; it \
508                     was read as `relative`, from the tube's surface to the pod's"
509                ),
510            );
511            // The design holds a distance from the axis, not this word: kept as written.
512            values.forget(&["radiusoffset"]);
513            tube_radius_m + pod_radius_m + number
514        }
515    };
516    if method != "free" && parent_auto.contains(&AutoDimension::OuterRadius) {
517        if tube_radius_m <= 0.0 {
518            values.warn_at(
519                WarningKind::Skipped,
520                format!(
521                    "the tube's radius is automatic with no number cached, and hpr places pods at \
522                     a fixed distance from the axis, so where they sit is not known; the pod set \
523                     was left out{}",
524                    and_what_was_inside(element)
525                ),
526            );
527            return None;
528        }
529        values.warn_at(
530            WarningKind::Unusual,
531            format!(
532                "the tube's radius is automatic, and hpr places pods at a fixed distance from the \
533                 axis: they were placed {radial_offset_m} m from it, by the tube radius OpenRocket \
534                 cached, {tube_radius_m} m"
535            ),
536        );
537        // The design holds the distance this cached radius gives, where the file says the pods
538        // follow the tube: the file's offset is kept as written.
539        values.forget(&["radiusoffset"]);
540    }
541    if !(radial_offset_m >= 0.0 && radial_offset_m.is_finite()) {
542        values.warn_at(
543            WarningKind::Skipped,
544            format!(
545                "the pods would sit {radial_offset_m} m from the axis, which is no distance; the \
546                 pod set was left out{}",
547                and_what_was_inside(element)
548            ),
549        );
550        return None;
551    }
552
553    // Only now that the pod set is read: what is dropped from it next.
554    if pods.is_empty() && (overrides != Overrides::default() || drag_override.is_some()) {
555        // OpenRocket 24.12 puts an override on a pod set that holds nothing at the rocket's tip,
556        // on the axis (`pods.py`), which no design means.
557        overrides = Overrides::default();
558        drag_override = None;
559        values.warn_at(
560            WarningKind::Dropped,
561            "an override on a pod set that holds nothing, which OpenRocket weighs at the rocket's \
562             tip; it was left out",
563        );
564        values.forget(&["overridemass", "overridecg", "overridecd"]);
565        values.forget(&OVERRIDE_FLAGS);
566    } else if overrides != Overrides::default() && !include_children {
567        // A pod set weighs nothing of its own, so an override on it can only be its pods' total,
568        // as a stage's is; `hpr-design` refuses one that does not say it covers them.
569        values.warn_at(
570            WarningKind::Dropped,
571            "an override on a pod set that does not cover its pods; a pod set weighs nothing of \
572             its own, so it was read as covering them",
573        );
574        values.forget(&OVERRIDE_FLAGS);
575    }
576    // A tube of no length has no wall for a fin's tab to sit in either: the tab would reach below
577    // the tube's radius.
578    for pod in pods.iter_mut().filter(|pod| flat(pod)) {
579        let radius_m = match &pod.part {
580            Part::BodyTube(tube) => tube.outer_radius_m,
581            _ => 0.0,
582        };
583        for child in &mut pod.children {
584            if let Part::FinSet(fins) = &mut child.part
585                && fins.tab.as_ref().is_some_and(|tab| tab.height_m > radius_m)
586            {
587                fins.tab = None;
588                values.warn_at(
589                    WarningKind::Dropped,
590                    format!(
591                        "a tab on the fins `{}` on a pod's tube of no length, deeper than the \
592                         tube's radius ({radius_m} m); the tab was left out, and the fins read \
593                         without it",
594                        child.id
595                    ),
596                );
597            }
598        }
599    }
600
601    ids.read.insert(at.to_owned());
602    Some(Component {
603        id,
604        name,
605        part: Part::PodSet(PodSet {
606            count,
607            radial_offset_m,
608            angle_rad,
609        }),
610        position: Some(position),
611        auto: Vec::new(),
612        motor_mount: None,
613        finish: None,
614        overrides,
615        overrides_include_children: true,
616        drag_override,
617        children: pods,
618    })
619}
620
621/// Reads one parallel stage (ADR-171), or leaves it out with a warning: a stage of its own,
622/// strapped beside the body tube it is written in, as boosters are.
623///
624/// OpenRocket's parallel stage places its copies as a pod set places its pods, by the same
625/// `instancecount`, `angleoffset`, `radiusoffset` and axial offset, so it is read by [`pod_set`]'s
626/// rules, its warnings saying "parallel stage". It becomes a [`Stage`] with
627/// [`ParallelStage`] set, its body components the pod's stack, its overrides the stage's, and its
628/// separation read as a stage's is; the tube's id, taken after its children are read, is filled
629/// in by [`body`]. The stage reader puts it right after the stage it hangs on, which is where
630/// OpenRocket numbers it on a rocket of one axial stage. On a rocket of several, inside a pod or
631/// another parallel stage, with no body component in it, or placed after the part before it, it
632/// is left out.
633fn parallel_stage(
634    element: &Element,
635    parent: &Part,
636    parent_auto: &[AutoDimension],
637    at: &str,
638    ids: &mut Ids,
639    skipped: &mut Vec<String>,
640    warnings: &mut Vec<Warning>,
641) -> Option<Stage> {
642    let leave_out = |why: &str, warnings: &mut Vec<Warning>| {
643        Values::new(element, at, warnings).warn_at(
644            WarningKind::Skipped,
645            format!(
646                "a parallel stage {why}, which hpr does not read yet; it was left out{}",
647                and_what_was_inside(element)
648            ),
649        );
650    };
651    if !ids.parallel_stages_read {
652        leave_out("on a rocket of more than one axial stage", warnings);
653        return None;
654    }
655    if at.matches("/podset[").count() + at.matches("/parallelstage[").count() > 1 {
656        leave_out("inside a pod or another parallel stage", warnings);
657        return None;
658    }
659    // A stage is a stack of at least one body component (`hpr-design`).
660    if !subcomponents(element).any(|child| BODY_TAGS.contains(&child.name.as_str())) {
661        leave_out("with no nose cone, body tube or transition in it", warnings);
662        return None;
663    }
664    // Looked at before anything inside it is read, so a stage left out leaves no mount behind;
665    // `pod_set` reads the position again, and says what it says about it then.
666    let mut scratch = Vec::new();
667    if matches!(
668        position(&mut Values::new(element, at, &mut scratch)),
669        Position::After { .. }
670    ) {
671        leave_out("placed after the part before it", warnings);
672        return None;
673    }
674    let first = warnings.len();
675    let pods = pod_set(element, parent, parent_auto, at, ids, skipped, warnings);
676    for warning in &mut warnings[first..] {
677        if warning.at == at {
678            warning.message = warning.message.replace("pod set", "parallel stage");
679        }
680    }
681    let pods = pods?;
682    let Part::PodSet(set) = pods.part else {
683        unreachable!("`pod_set` reads a pod set")
684    };
685    // `pod_set` always places what it reads, and not after a sibling, as looked at above.
686    let position = pods.position.unwrap_or(Position::Top { aft_offset_m: 0.0 });
687    if let Some(separation) = recovery::separation(element, at, warnings) {
688        ids.separations.push((pods.id.clone(), separation));
689    }
690    Some(Stage {
691        id: pods.id,
692        name: pods.name,
693        components: pods.children,
694        overrides: pods.overrides,
695        drag_override: pods.drag_override,
696        parallel: Some(ParallelStage {
697            on: String::new(),
698            position,
699            pods: set,
700        }),
701    })
702}
703
704/// The radius OpenRocket 24.12 gives an `innertube` whose outer radius is written `auto`: its
705/// inner tube has no automatic radius, so it keeps the 9.5 mm it starts with, in a body tube or a
706/// nose cone alike, where a coupler or an engine block fills its parent. Measured on probe designs
707/// (`validation/oracles/openrocket/conventions.py`, [ADR-096][adr-096]).
708///
709/// [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
710const INNER_TUBE_UNRESOLVED_M: f64 = 0.0095;
711
712/// An inner tube, tube coupler or engine block: all three are a tube inside another one.
713fn inner_tube(tag: &str, values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
714    let length_m = values.number(&["length"]).unwrap_or_default();
715    let (mut stated_m, mut outer_radius_m) =
716        stated_radius(values, &["outerradius"], AutoDimension::OuterRadius, auto);
717    if tag == "innertube" && auto.contains(&AutoDimension::OuterRadius) {
718        auto.retain(|dimension| *dimension != AutoDimension::OuterRadius);
719        // OpenRocket's own reading, measured, so no warning: one would keep the design from
720        // flying (ADR-055) for a part read exactly as OpenRocket reads it.
721        (stated_m, outer_radius_m) = (Some(INNER_TUBE_UNRESOLVED_M), INNER_TUBE_UNRESOLVED_M);
722    }
723    let thickness_m = tube_wall(values, stated_m)?;
724    let angle_rad = roll_angle(values);
725    let cluster_m = cluster(values, outer_radius_m, angle_rad);
726    Some(Part::InnerTube(InnerTube {
727        length_m,
728        outer_radius_m,
729        thickness_m,
730        radial_offset_m: values.number(&["radialposition"]).unwrap_or_default(),
731        angle_rad,
732        material: material(values, &["material"], "bulk"),
733        cluster_m,
734    }))
735}
736
737/// A clustered tube's places ([`InnerTube::cluster_m`]), or none for one tube.
738///
739/// `clusterconfiguration` names one of OpenRocket's patterns ([`cluster_pattern`]), whose points
740/// are in units of the separation between neighbouring tubes' axes: `2 R s`, for the tube's outer
741/// radius `R` and `clusterscale` `s` (1 unless written), so tubes of scale 1 touch. The pattern is
742/// turned by the tube's roll angle `θ` less `clusterrotation` `ρ` (degrees, as every `.ork` angle):
743///
744/// `[x, y]ₖ = 2 R s · Rot(θ − ρ) · pₖ`
745///
746/// with OpenRocket's `(y, z)` read as hpr's `(x, y)`, as the roll angle is ([`roll_angle`]). No
747/// document gives the patterns or this rule; OpenRocket 24.12 was asked, as an external oracle, on
748/// probes of every pattern, of a scale, a rotation, a radial offset and all three at once
749/// (`validation/oracles/openrocket/clusters.py`), and `hpr_validate`'s tests hold every tube of
750/// its record to this reading. A name OpenRocket has no pattern for, it reads as one tube; so does
751/// this reader, with a warning.
752fn cluster(values: &mut Values<'_>, outer_radius_m: f64, angle_rad: f64) -> Vec<[f64; 2]> {
753    let Some(name) = values.word(&["clusterconfiguration"]) else {
754        return Vec::new();
755    };
756    let Some(points) = cluster_pattern(&name) else {
757        values.warn_at(
758            WarningKind::Dropped,
759            format!(
760                "`{name}` is not one of OpenRocket's cluster patterns, so the tube is read as one \
761                 tube, as OpenRocket reads it"
762            ),
763        );
764        values.forget(&["clusterconfiguration"]);
765        return Vec::new();
766    };
767    let scale = values.number(&["clusterscale"]).unwrap_or(1.0);
768    let rotation_rad = values
769        .number(&["clusterrotation"])
770        .unwrap_or_default()
771        .to_radians();
772    if name == "single" {
773        return Vec::new();
774    }
775    let separation_m = 2.0 * outer_radius_m * scale;
776    let (sin, cos) = (angle_rad - rotation_rad).sin_cos();
777    points
778        .iter()
779        .map(|&[u, v]| {
780            [
781                separation_m * (u * cos - v * sin),
782                separation_m * (u * sin + v * cos),
783            ]
784        })
785        .collect()
786}
787
788/// OpenRocket's cluster pattern called `name` in a `.ork`: each tube's place in units of the
789/// separation between neighbouring tubes' axes, in the order OpenRocket lists them. These are the
790/// points OpenRocket 24.12 gives, as measured by `validation/oracles/openrocket/clusters.py`
791/// (`validation/fixtures/ork/openrocket-clusters.json`), written as the figures they are: rows
792/// one apart, a triangle and a square of side one, rings of radius one round a center tube (the
793/// stars), a pentagon of side one, and a grid and an eight-ring of spacing 1.4. `None` for a name
794/// OpenRocket has no pattern for.
795pub(crate) fn cluster_pattern(name: &str) -> Option<Vec<[f64; 2]>> {
796    // `n` places on a circle of radius `r`, the first at `start` degrees, running clockwise.
797    let ring = |n: u32, r: f64, start: f64| -> Vec<[f64; 2]> {
798        (0..n)
799            .map(|k| {
800                let (sin, cos) = (start - 360.0 * f64::from(k) / f64::from(n))
801                    .to_radians()
802                    .sin_cos();
803                [r * cos, r * sin]
804            })
805            .collect()
806    };
807    let star = |mut points: Vec<[f64; 2]>| {
808        points.insert(0, [0.0, 0.0]);
809        points
810    };
811    let third = 3.0_f64.sqrt() / 6.0;
812    Some(match name {
813        "single" => vec![[0.0, 0.0]],
814        "double" => vec![[-0.5, 0.0], [0.5, 0.0]],
815        "3-row" => vec![[-1.0, 0.0], [0.0, 0.0], [1.0, 0.0]],
816        "3-ring" => vec![[-0.5, -third], [0.5, -third], [0.0, 2.0 * third]],
817        "3-star" => star(ring(3, 1.0, 90.0)),
818        "4-row" => vec![[-1.5, 0.0], [-0.5, 0.0], [0.5, 0.0], [1.5, 0.0]],
819        "4-ring" => vec![[-0.5, 0.5], [0.5, 0.5], [0.5, -0.5], [-0.5, -0.5]],
820        "4-star" => star(ring(4, 1.0, 135.0)),
821        // A pentagon of side one has circumradius 1 / (2 sin 36°).
822        "5-ring" => ring(5, 0.5 / 36.0_f64.to_radians().sin(), 90.0),
823        "5-star" => star(ring(5, 1.0, 90.0)),
824        "6-ring" => ring(6, 1.0, 90.0),
825        "6-star" => star(ring(6, 1.0, 90.0)),
826        "9-grid" => [1.4, 0.0, -1.4]
827            .into_iter()
828            .flat_map(|y| [-1.4, 0.0, 1.4].map(|x| [x, y]))
829            .collect(),
830        "9-star" => star(ring(8, 1.4, 0.0)),
831        _ => return None,
832    })
833}
834
835/// A tube's wall, in meters, or `None` when the tube cannot be read at all.
836///
837/// A tube of **no** wall thickness is read as exactly that, and carries no mass: OpenRocket 24.12
838/// gives an inner tube, a coupler and a lug of no wall no mass either, measured on a probe design
839/// ([ADR-061][adr-061]), so it is read as written and not warned of. Reading it as solid would
840/// invent the mass: a solid coupler filling a 50 mm airframe for 180 mm is a few hundred grams the
841/// design never had. Seven tube couplers in the reference corpus are written this way, three of
842/// them in two of OpenRocket's own example designs, and four launch lugs say the same of
843/// themselves.
844///
845/// [adr-061]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-061-what-a-ork-leaves-unsaid-read-as-openrocket-reads-it-overrides-measured-two-departures-kept-2026-09-21
846fn tube_wall(values: &mut Values<'_>, outer_radius_m: Option<f64>) -> Option<f64> {
847    if values.word(&["thickness"]).as_deref() == Some("filled") {
848        return match outer_radius_m {
849            Some(radius_m) => Some(radius_m),
850            None => {
851                values.warn_at(
852                    WarningKind::Skipped,
853                    "a filled tube whose outer radius is automatic; there is no radius to fill \
854                     until the layout resolves one, so it was left out",
855                );
856                None
857            }
858        };
859    }
860    let Some(thickness_m) = values.number(&["thickness"]) else {
861        // A *stated* zero is OpenRocket saying the bore reaches the rim. A missing tag says
862        // nothing at all. OpenRocket 24.12 gives it a wall of its own: on the probe design, 0.5 mm
863        // for a 20 mm inner tube, 1 mm for a 5 mm lug, and none for a coupler (ADR-061). One size
864        // each does not say whether that wall follows the radius, and nothing in the reference
865        // library omits it, so it is read as a tube of no wall, out loud, and the test
866        // `hpr_validate::openrocket::tests` pins the difference.
867        values.warn_at(
868            WarningKind::Dropped,
869            "a tube with no wall thickness at all; it was read as a tube of no wall, which \
870             carries no mass, rather than as the solid rod a filled one would be",
871        );
872        return Some(0.0);
873    };
874    if thickness_m == 0.0 {
875        return Some(0.0);
876    }
877    if thickness_m < 0.0 {
878        values.warn_at(
879            WarningKind::Skipped,
880            "a tube of negative wall thickness; it was left out",
881        );
882        return None;
883    }
884    // A wall thicker than the tube is the tube solid. With an automatic radius there is no number
885    // to compare against yet, so the wall stands as written and the layout decides.
886    Some(match outer_radius_m {
887        Some(radius_m) => thickness_m.min(radius_m),
888        None => thickness_m,
889    })
890}
891
892/// A centering ring, or a bulkhead, which is a ring with no bore.
893fn ring(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>, bored: bool) -> Option<Part> {
894    let length_m = values.number(&["length"]).unwrap_or_default();
895    let (_, outer_radius_m) =
896        stated_radius(values, &["outerradius"], AutoDimension::OuterRadius, auto);
897    let inner_radius_m = if bored {
898        stated_radius(values, &["innerradius"], AutoDimension::InnerRadius, auto).1
899    } else {
900        0.0
901    };
902    if inner_radius_m > outer_radius_m && !auto.contains(&AutoDimension::OuterRadius) {
903        values.warn_at(
904            WarningKind::Skipped,
905            "a ring whose bore is wider than the ring; it was left out",
906        );
907        return None;
908    }
909    instanced_once(values, "ring");
910    off_the_axis(values, "ring");
911    Some(Part::CenteringRing(CenteringRing {
912        length_m,
913        outer_radius_m,
914        inner_radius_m,
915        material: material(values, &["material"], "bulk"),
916    }))
917}
918
919/// A fin set of any of the three outlines OpenRocket writes.
920fn fin_set(element: &Element, values: &mut Values<'_>) -> Option<Part> {
921    let planform = match element.name.as_str() {
922        "trapezoidfinset" => FinPlanform::Trapezoidal {
923            root_chord_m: values.number(&["rootchord"]).unwrap_or_default(),
924            tip_chord_m: values.number(&["tipchord"]).unwrap_or_default(),
925            span_m: values.number(&["height"]).unwrap_or_default(),
926            sweep_m: values.number(&["sweeplength"]).unwrap_or_default(),
927        },
928        "ellipticalfinset" => FinPlanform::Elliptical {
929            root_chord_m: values.number(&["rootchord"]).unwrap_or_default(),
930            span_m: values.number(&["height"]).unwrap_or_default(),
931        },
932        _ => outline(values)?,
933    };
934    let count = instances(values, "fin set")?;
935    // Fillets weigh what OpenRocket 24.12 weighs them, in their own material, which is cardboard
936    // when none is named, as for any solid part (ADR-096).
937    let fillet = values
938        .number(&["filletradius"])
939        .filter(|radius_m| *radius_m > 0.0)
940        .map(|radius_m| FinFillet {
941            radius_m,
942            material: material(values, &["filletmaterial"], "bulk"),
943        });
944    Some(Part::FinSet(FinSet {
945        count,
946        thickness_m: values.number(&["thickness"]).unwrap_or_default(),
947        cross_section: cross_section(values),
948        tab: tab(values, planform.root_chord_m()),
949        fillet,
950        // Degrees, like every other angle in the file (see `roll_angle`). The corpus's two
951        // non-zero cants are 1.0 and -3.98, which as radians would be 57° and 228°: a fin turned
952        // more than half a turn from the airflow, which is not a cant anyone builds and which
953        // OpenRocket's own roll model (Niskanen eq. 3.10, `δ` small) would not mean.
954        cant_rad: values.number(&["cant"]).unwrap_or_default().to_radians(),
955        base_angle_rad: roll_angle(values),
956        material: material(values, &["material"], "bulk"),
957        planform,
958    }))
959}
960
961/// How many straight pieces [`follow_the_body`] reads a fin's root along a nose cone or a
962/// transition as. On the *Pods--airframes and winglets* example's cockpit, an ogive's last 50 mm,
963/// the chord of each piece stands at most 0.14 µm inside the curve.
964pub(super) const ROOT_PIECES: u32 = 64;
965
966/// A fin set's root, read where it sits ([ADR-166][adr-166]).
967///
968/// OpenRocket measures a fin's points from the body's surface at the root leading edge and runs
969/// the root along the surface, so on a body tube the outline ends at `h = 0`, and on a nose cone or
970/// a transition it ends on the surface: `h = r(x_LE + c) − r(x_LE)`. On a body tube an outline that
971/// ends anywhere else is a fin whose trailing edge stands clear of the body, and there is no honest
972/// way to read it as a fin that touches: closing it along the root would add planform the design
973/// does not have. On a nose cone or a transition the root is drawn through [`ROOT_PIECES`] − 1
974/// points on the surface, and checked as `hpr-design`'s layout checks it
975/// ([`FinSet::root_radius_on`]), so a root this reads never fails the layout. Only a freeform fin
976/// set placed from its parent's ends or middle, with no tab or fillet, on a body of stated radii
977/// that does not narrow along its root, is read there; anything else says why. An automatic
978/// radius is the layout's to resolve from the parts around it, after this reader has drawn the
979/// root: a root drawn on the radius OpenRocket cached could stand off the surface the layout
980/// settles on.
981///
982/// [adr-166]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0166-a-fin-root-that-follows-the-body.md
983fn follow_the_body(
984    part: Part,
985    parent: &Part,
986    parent_auto: &[AutoDimension],
987    position: Position,
988) -> Result<Part, String> {
989    let Part::FinSet(mut fins) = part else {
990        return Ok(part);
991    };
992    let surface = match parent {
993        Part::NoseCone(nose) => nose.profile(),
994        Part::Transition(transition) => transition.profile(),
995        _ => {
996            return match fins.check_level_root() {
997                Ok(()) => Ok(Part::FinSet(fins)),
998                Err(_) => Err(
999                    "a freeform fin outline that does not run from the root leading \
1000                     edge to the root trailing edge; it was left out rather than closed along a \
1001                     root it never touches"
1002                        .to_owned(),
1003                ),
1004            };
1005        }
1006    };
1007    let on = spoken(parent);
1008    let automatic = parent_auto.iter().any(|dimension| {
1009        matches!(
1010            dimension,
1011            AutoDimension::BaseRadius | AutoDimension::ForeRadius | AutoDimension::AftRadius
1012        )
1013    });
1014    if automatic {
1015        return Err(format!(
1016            "a fin set on a {on} of automatic radius, whose surface the layout settles only after \
1017             this reader draws the root; it was left out"
1018        ));
1019    }
1020    let surface = surface.map_err(|e| {
1021        format!("a fin set on a {on} whose surface is unreadable ({e}); it was left out")
1022    })?;
1023    if fins.tab.is_some() || fins.fillet.is_some() {
1024        return Err(format!(
1025            "a fin set with a tab or a fillet on a {on}, which hpr models only on a body tube; it \
1026             was left out"
1027        ));
1028    }
1029    let chord = fins.planform.root_chord_m();
1030    let FinPlanform::Freeform { points_m, root_m } = &mut fins.planform else {
1031        return Err(format!(
1032            "a {} fin set on a {on}: OpenRocket runs its root along the surface, which hpr reads \
1033             only from a freeform outline; it was left out",
1034            match fins.planform {
1035                FinPlanform::Trapezoidal { .. } => "trapezoidal",
1036                FinPlanform::Elliptical { .. } => "elliptical",
1037                _ => "non-freeform",
1038            }
1039        ));
1040    };
1041    let length = surface.length_m();
1042    let fore = match position {
1043        Position::Top { aft_offset_m } => aft_offset_m,
1044        Position::Middle { aft_offset_m } => 0.5 * (length - chord) + aft_offset_m,
1045        Position::Bottom { aft_offset_m } => length - chord + aft_offset_m,
1046        Position::After { .. } | Position::Absolute { .. } => {
1047            return Err(format!(
1048                "a fin set on a {on} placed after its sibling or at an absolute station, where this \
1049                 reader can't find the surface its root follows; it was left out"
1050            ));
1051        }
1052    };
1053    // The layout's own slack: `length − chord + chord` can round a ulp past the end.
1054    let slack = FinSet::ROOT_ON_SURFACE_M;
1055    if !(fore >= -slack && fore + chord <= length + slack) {
1056        return Err(format!(
1057            "a fin set whose root runs past the ends of the {on} it sits on; it was left out"
1058        ));
1059    }
1060    let base = surface.radius_m(fore);
1061    let rise = |x: f64| surface.radius_m(fore + x) - base;
1062    let pieces = f64::from(ROOT_PIECES);
1063    let stations = (1..=ROOT_PIECES).map(|i| chord * f64::from(i) / pieces);
1064    if stations.clone().any(|x| rise(x) < 0.0) {
1065        return Err(format!(
1066            "a fin set on a {on} that narrows along its root, which hpr reads only where the body \
1067             holds or grows; it was left out"
1068        ));
1069    }
1070    let end = points_m.last().copied().unwrap_or_default();
1071    let miss = end[1] - rise(chord);
1072    if miss.abs() > FinSet::ROOT_ON_SURFACE_M {
1073        return Err(format!(
1074            "a freeform fin outline on a {on} that ends {miss} m off its surface, where \
1075             OpenRocket's root ends; it was left out"
1076        ));
1077    }
1078    *root_m = stations
1079        .take(ROOT_PIECES as usize - 1)
1080        .map(|x| [x, rise(x)])
1081        .collect();
1082    root_m.reverse();
1083    // The layout's check, so that what this reads lays out: a root's slivers across the curve,
1084    // and anything the checks above did not foresee.
1085    fins.root_radius_on(&surface, fore).map_err(|e| {
1086        format!("a fin set on a {on} whose root can't be drawn on it ({e}); it was left out")
1087    })?;
1088    Ok(Part::FinSet(fins))
1089}
1090
1091/// A freeform fin's outline, as `[x, h]` from the root leading edge, ending at the root trailing
1092/// edge, which [`follow_the_body`] checks against the body the fin sits on.
1093fn outline(values: &mut Values<'_>) -> Option<FinPlanform> {
1094    let Some(points) = values.element(&["finpoints"]) else {
1095        values.warn_at(
1096            WarningKind::Skipped,
1097            "a freeform fin set with no outline; it was left out",
1098        );
1099        return None;
1100    };
1101    let mut points_m = Vec::new();
1102    super::reads::note(points, "point");
1103    for point in points.elements().filter(|point| point.name == "point") {
1104        let read = |name: &str| {
1105            point
1106                .attribute(name)
1107                .and_then(|text| text.parse::<f64>().ok())
1108                .filter(|value| value.is_finite())
1109        };
1110        match (read("x"), read("y")) {
1111            (Some(x), Some(h)) => points_m.push([x, h]),
1112            _ => {
1113                values.warn_at(
1114                    WarningKind::Skipped,
1115                    "a freeform fin outline with a point that is not two numbers; it was left out",
1116                );
1117                return None;
1118            }
1119        }
1120    }
1121    if points_m.len() < 3 {
1122        values.warn_at(
1123            WarningKind::Skipped,
1124            "a freeform fin outline of fewer than three points, which encloses no fin; it was \
1125             left out",
1126        );
1127        return None;
1128    }
1129    let ends_aft = matches!(points_m.last(), Some(last) if last[0] > 0.0);
1130    if points_m.first() != Some(&[0.0, 0.0]) || !ends_aft {
1131        values.warn_at(
1132            WarningKind::Skipped,
1133            "a freeform fin outline that does not run from the root leading edge to the root \
1134             trailing edge; it was left out rather than closed along a root it never touches",
1135        );
1136        return None;
1137    }
1138    Some(FinPlanform::Freeform {
1139        points_m,
1140        root_m: Vec::new(),
1141    })
1142}
1143
1144/// The section shape along a fin's chord.
1145fn cross_section(values: &mut Values<'_>) -> FinCrossSection {
1146    match values.word(&["crosssection"]).as_deref() {
1147        Some("rounded") => FinCrossSection::Rounded,
1148        Some("airfoil") => FinCrossSection::Airfoil,
1149        None | Some("square") => FinCrossSection::Square,
1150        Some(other) => {
1151            let other = other.to_owned();
1152            values.warn_at(
1153                WarningKind::Unusual,
1154                format!("`{other}` is not a fin section this reader knows; it was read as square"),
1155            );
1156            values.forget(&["crosssection"]);
1157            FinCrossSection::Square
1158        }
1159    }
1160}
1161
1162/// The tab below a fin's root, if it has one.
1163///
1164/// OpenRocket measures the tab from the fin's front, its center or its end, saying which in
1165/// `relativeto`; `hpr-design` states one thing, the distance from the root leading edge aft to the
1166/// tab's leading edge.
1167///
1168/// **What each word measures from is read off the corpus, not a specification.** `center` is taken
1169/// as center-to-center, which is the only reading under which the corpus's eight non-zero offsets
1170/// land inside their root chords. Any other puts a tab off the end of the fin, which
1171/// `FinSet::validate` refuses. `end` is the mirror of `front` and appears in no file at all, so
1172/// that arm rests on symmetry alone.
1173fn tab(values: &mut Values<'_>, root_chord_m: f64) -> Option<FinTab> {
1174    let height_m = values.number(&["tabheight"])?;
1175    let length_m = values.number(&["tablength"]).unwrap_or_default();
1176    if height_m <= 0.0 || length_m <= 0.0 {
1177        return None;
1178    }
1179    let element = values.element(&["tabposition"]);
1180    let relative_to = element
1181        .and_then(|element| element.attribute("relativeto"))
1182        .unwrap_or("front")
1183        .to_owned();
1184    let offset_m = values.number(&["tabposition"]).unwrap_or_default();
1185    let offset_m = match relative_to.as_str() {
1186        // OpenRocket writes `<tabposition>` twice, the newer name's frame first. The older
1187        // vocabulary is the same three places under the names the axial offset uses, and across
1188        // the reference library the two elements always carry the same number, so either spelling
1189        // means the same tab.
1190        "front" | "top" => offset_m,
1191        "center" | "middle" => 0.5 * (root_chord_m - length_m) + offset_m,
1192        "end" | "bottom" => root_chord_m - length_m + offset_m,
1193        other => {
1194            values.warn_at(
1195                WarningKind::Unusual,
1196                format!(
1197                    "a fin tab measured from `{other}`, which this reader does not know; it was \
1198                     read from the root leading edge"
1199                ),
1200            );
1201            values.forget(&["tabposition"]);
1202            offset_m
1203        }
1204    };
1205    Some(FinTab {
1206        height_m,
1207        length_m,
1208        offset_m,
1209    })
1210}
1211
1212/// OpenRocket 24.12 reads a tube fin set of more than this many tubes as this many, whether its
1213/// radius is stated or `auto`: 9, 12, 20 and 100 all read as 8 on its probes (ADR-098).
1214const MOST_TUBE_FINS: u32 = 8;
1215
1216/// A ring of tubes around the body. An `auto` radius is the one at which the tubes close the ring
1217/// around the body tube, which the layout resolves (`AutoDimension::OuterRadius`, ADR-098).
1218fn tube_fins(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1219    let (stated_m, outer_radius_m) =
1220        stated_radius(values, &["radius"], AutoDimension::OuterRadius, auto);
1221    let thickness_m = tube_wall(values, stated_m)?;
1222    // The count is read once, and capped before `MOST_INSTANCES` could leave the set out, so that
1223    // a count past it reads as 8 too, as OpenRocket reads 100 on its probe.
1224    let count = match values.count(&INSTANCE_COUNT).unwrap_or(1) {
1225        0 => {
1226            values.warn_at(
1227                WarningKind::Skipped,
1228                "a tube fin set of none; it was left out",
1229            );
1230            return None;
1231        }
1232        written if written > MOST_TUBE_FINS => {
1233            values.warn_at(
1234                WarningKind::Dropped,
1235                format!(
1236                    "a tube fin set of {written} tubes; OpenRocket 24.12 reads at most \
1237                     {MOST_TUBE_FINS}, so it was read as {MOST_TUBE_FINS}"
1238                ),
1239            );
1240            values.forget(&INSTANCE_COUNT);
1241            MOST_TUBE_FINS
1242        }
1243        written => written,
1244    };
1245    Some(Part::TubeFinSet(TubeFinSet {
1246        count,
1247        length_m: values.number(&["length"]).unwrap_or_default(),
1248        outer_radius_m,
1249        thickness_m,
1250        base_angle_rad: roll_angle(values),
1251        material: material(values, &["material"], "bulk"),
1252    }))
1253}
1254
1255/// A launch lug, or a row of them.
1256fn launch_lug(values: &mut Values<'_>) -> Option<Part> {
1257    let mut auto = Vec::new();
1258    let (stated_m, outer_radius_m) =
1259        stated_radius(values, &["radius"], AutoDimension::OuterRadius, &mut auto);
1260    if stated_m.is_none() {
1261        // A lug has no automatic radius in `hpr-design`, and the number OpenRocket cached is the
1262        // rod it last had, which may not be this one's. Reading it would put a stated radius where
1263        // the file says "work it out", and a bare `auto` would leave a lug of no radius that the
1264        // layout refuses, taking the whole design with it. No lug in the reference library is
1265        // written this way.
1266        values.warn_at(
1267            WarningKind::Skipped,
1268            "a launch lug whose radius OpenRocket works out for itself; hpr does not resolve that, \
1269             so it was left out",
1270        );
1271        return None;
1272    }
1273    let thickness_m = tube_wall(values, stated_m)?;
1274    let (count, spacing_m) = row(values, "row of launch lugs")?;
1275    Some(Part::LaunchLug(LaunchLug {
1276        length_m: values.number(&["length"]).unwrap_or_default(),
1277        outer_radius_m,
1278        thickness_m,
1279        angle_rad: roll_angle(values),
1280        count,
1281        spacing_m,
1282        material: material(values, &["material"], "bulk"),
1283    }))
1284}
1285
1286/// A rail button, or a row of them.
1287fn rail_button(values: &mut Values<'_>) -> Option<Part> {
1288    let (count, spacing_m) = row(values, "row of rail buttons")?;
1289    Some(Part::RailButton(RailButton {
1290        outer_diameter_m: values.number(&["outerdiameter"]).unwrap_or_default(),
1291        inner_diameter_m: values.number(&["innerdiameter"]).unwrap_or_default(),
1292        height_m: values.number(&["height"]).unwrap_or_default(),
1293        base_height_m: values.number(&["baseheight"]).unwrap_or_default(),
1294        flange_height_m: values.number(&["flangeheight"]).unwrap_or_default(),
1295        screw_height_m: values.number(&["screwheight"]).unwrap_or_default(),
1296        angle_rad: roll_angle(values),
1297        count,
1298        spacing_m,
1299        material: material_or(values, &["material"], "bulk", UNNAMED_RAIL_BUTTON),
1300    }))
1301}
1302
1303/// A lump of mass with no geometry of its own beyond how it is packed.
1304fn mass_component(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1305    Some(Part::MassComponent(MassComponent {
1306        mass_kg: values.number(&["mass"]).unwrap_or_default(),
1307        packing: packing(values, auto),
1308    }))
1309}
1310
1311/// A parachute: a canopy of fabric on a set of shroud lines, packed into the body.
1312fn parachute(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1313    Some(Part::Parachute(Parachute {
1314        diameter_m: values.number(&["diameter"]).unwrap_or_default(),
1315        canopy_material: material(values, &["material"], "surface"),
1316        line_count: values.count(&["linecount"]).unwrap_or_default(),
1317        line_length_m: values.number(&["linelength"]).unwrap_or_default(),
1318        line_material: material(values, &["linematerial"], "line"),
1319        packing: packing(values, auto),
1320    }))
1321}
1322
1323/// A streamer: a strip of fabric, packed into the body.
1324fn streamer(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1325    Some(Part::Streamer(Streamer {
1326        length_m: values.number(&["striplength"]).unwrap_or_default(),
1327        width_m: values.number(&["stripwidth"]).unwrap_or_default(),
1328        material: material(values, &["material"], "surface"),
1329        packing: packing(values, auto),
1330    }))
1331}
1332
1333/// A shock cord, packed into the body.
1334fn shock_cord(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1335    Some(Part::ShockCord(ShockCord {
1336        length_m: values.number(&["cordlength"]).unwrap_or_default(),
1337        material: material(values, &["material"], "line"),
1338        packing: packing(values, auto),
1339    }))
1340}
1341
1342/// The packed length OpenRocket 24.12 gives a mass component, parachute, streamer or shock cord
1343/// whose file writes no `packedlength`, in meters: 25 mm.
1344///
1345/// No document states it, so it was measured ([ADR-063][adr-063]): on probe designs of each kind
1346/// written with no packed size, and a parachute written with only one of the two, OpenRocket's
1347/// center of mass and inertias are those of a solid cylinder this long, from the part's position
1348/// aft, and [`PACKED_RADIUS_M`] in radius, whichever of the two is missing
1349/// (`validation/oracles/openrocket/conventions.py`).
1350///
1351/// [adr-063]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-063-packed-parts-read-and-weighed-as-openrocket-packs-them-2026-09-21
1352const PACKED_LENGTH_M: f64 = 0.025;
1353
1354/// The packed radius OpenRocket 24.12 gives a packed part whose file writes no `packedradius`, in
1355/// meters: 12.5 mm, a fixed number, not the tube's bore. Measured as [`PACKED_LENGTH_M`] is.
1356const PACKED_RADIUS_M: f64 = 0.0125;
1357
1358/// How a mass object or a recovery part is packed: the cylinder it takes up inside the body. A size
1359/// the file does not write is OpenRocket's ([`PACKED_LENGTH_M`], [`PACKED_RADIUS_M`]), read with no
1360/// warning, as [ADR-061][adr-061] reads what else a file leaves unsaid (a wall of no thickness, no
1361/// material): that is what the file means, not a guess.
1362///
1363/// [adr-061]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-061-what-a-ork-leaves-unsaid-read-as-openrocket-reads-it-overrides-measured-two-departures-kept-2026-09-21
1364fn packing(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Packing {
1365    let radius_m = if values.element(&["packedradius"]).is_none() {
1366        PACKED_RADIUS_M
1367    } else {
1368        stated_radius(values, &["packedradius"], AutoDimension::PackedRadius, auto).1
1369    };
1370    let length_m = if values.element(&["packedlength"]).is_none() {
1371        PACKED_LENGTH_M
1372    } else {
1373        values.number(&["packedlength"]).unwrap_or_default()
1374    };
1375    Packing {
1376        length_m,
1377        radius_m,
1378        radial_offset_m: values.number(&["radialposition"]).unwrap_or_default(),
1379        angle_rad: roll_angle(values),
1380    }
1381}
1382
1383/// How many of an instanced part there are, and how far apart they sit along the body. `None` when
1384/// the file asks for more than [`MOST_INSTANCES`].
1385fn row(values: &mut Values<'_>, what: &str) -> Option<(u32, f64)> {
1386    let count = instances(values, what)?;
1387    Some((
1388        count.max(1),
1389        values.number(&["instanceseparation"]).unwrap_or_default(),
1390    ))
1391}
1392
1393/// How many of a part the file says there are, refusing a count no rocket has and a file could
1394/// only give by accident or on purpose. See [`MOST_INSTANCES`].
1395fn instances(values: &mut Values<'_>, what: &str) -> Option<u32> {
1396    let count = values.count(&INSTANCE_COUNT).unwrap_or(1);
1397    if count == 0 {
1398        let what = what.to_owned();
1399        values.warn_at(
1400            WarningKind::Skipped,
1401            format!("a {what} of none; it was left out"),
1402        );
1403        return None;
1404    }
1405    if count > MOST_INSTANCES {
1406        let what = what.to_owned();
1407        values.warn_at(
1408            WarningKind::Skipped,
1409            format!(
1410                "a {what} of {count}, where hpr builds each one and adds them up; it was left out \
1411                 rather than asked for"
1412            ),
1413        );
1414        return None;
1415    }
1416    Some(count)
1417}
1418
1419/// Says so when a part is written more than once and `hpr-design` models one of it.
1420fn instanced_once(values: &mut Values<'_>, what: &str) {
1421    if values.count(&INSTANCE_COUNT).is_some_and(|count| count > 1) {
1422        let what = what.to_owned();
1423        values.warn_at(
1424            WarningKind::Dropped,
1425            format!("a row of more than one {what} was read as the one it is written as"),
1426        );
1427        values.forget(&INSTANCE_COUNT);
1428    }
1429}
1430
1431/// Says so when a part that `hpr-design` keeps on the axis is written off it.
1432fn off_the_axis(values: &mut Values<'_>, what: &str) {
1433    if values
1434        .number(&["radialposition"])
1435        .is_some_and(|offset| offset != 0.0)
1436    {
1437        let what = what.to_owned();
1438        values.warn_at(
1439            WarningKind::Dropped,
1440            format!("a {what} off the body axis was read on it; hpr keeps one on the axis"),
1441        );
1442        values.forget(&["radialposition"]);
1443    }
1444}
1445
1446/// Says so when an external part is written standing off the body's surface.
1447///
1448/// OpenRocket measures a fin's or a lug's radius from the surface (`method="surface"`), or, for
1449/// tube fins, from the body it wraps (`"coaxial"`). `hpr-design` sits every external part on the
1450/// surface, so any offset but zero is a standoff it does not model. Every one of the 95 written in
1451/// the reference corpus is zero.
1452fn radial_offset_on_the_surface(values: &mut Values<'_>, part: &Part) {
1453    if !part.is_external() {
1454        return;
1455    }
1456    if values
1457        .number(&["radiusoffset"])
1458        .is_some_and(|offset| offset != 0.0)
1459    {
1460        let kind = spoken(part);
1461        values.warn_at(
1462            WarningKind::Dropped,
1463            format!("a {kind} standing off the body was read sitting on it"),
1464        );
1465        values.forget(&["radiusoffset"]);
1466    }
1467}
1468
1469/// Where a part sits along its parent.
1470///
1471/// The offset and the end it is measured from are one tag: `<axialoffset method="bottom">` on the
1472/// newer name, `<position type="bottom">` on the older, with the same five words. A part with
1473/// neither is read flush with its parent's forward end, which is what OpenRocket's own default is
1474/// for everything but a fin set; one tube coupler in the reference corpus is written that way.
1475fn position(values: &mut Values<'_>) -> Position {
1476    let Some(element) = values.element(&AXIAL_OFFSET) else {
1477        values.warn_at(
1478            WarningKind::Unusual,
1479            "no axial offset at all; the part was read flush with its parent's forward end",
1480        );
1481        return Position::Top { aft_offset_m: 0.0 };
1482    };
1483    let from = element
1484        .attribute("method")
1485        .or_else(|| element.attribute("type"))
1486        .unwrap_or("top")
1487        .to_owned();
1488    // From the element already found, not a second lookup: asking `Values` again would run the
1489    // two-name comparison a second time and warn twice about one disagreement.
1490    let text = element.text();
1491    let aft_offset_m = match text.trim().parse::<f64>() {
1492        Ok(value) if value.is_finite() => value,
1493        _ => {
1494            let (name, text) = (element.name.clone(), text.trim().to_owned());
1495            values.warn_at(
1496                WarningKind::Dropped,
1497                format!("`{name}` says `{text}`, which is not a number; it was read as zero"),
1498            );
1499            values.forget(&AXIAL_OFFSET);
1500            0.0
1501        }
1502    };
1503    match from.as_str() {
1504        "top" => Position::Top { aft_offset_m },
1505        "middle" => Position::Middle { aft_offset_m },
1506        "bottom" => Position::Bottom { aft_offset_m },
1507        "after" => Position::After { aft_offset_m },
1508        "absolute" => Position::Absolute {
1509            station_m: aft_offset_m,
1510        },
1511        other => {
1512            values.warn_at(
1513                WarningKind::Unusual,
1514                format!(
1515                    "an axial offset measured from `{other}`, which this reader does not know; it \
1516                     was read from the parent's forward end"
1517                ),
1518            );
1519            values.forget(&AXIAL_OFFSET);
1520            Position::Top { aft_offset_m }
1521        }
1522    }
1523}
1524
1525/// Where `hpr-design` must put a rail button's forward edge for its center to be where OpenRocket
1526/// puts it.
1527///
1528/// OpenRocket 24.12 gives a rail button no length, so it places the button's center where a part of
1529/// no length would sit, from whichever end the offset is measured, and a row's first button there,
1530/// the rest following aft (#151; measured on `validation/oracles/openrocket/conventions.py`'s
1531/// probes: from the top, the middle and the bottom, one button and a row of two). `hpr-design`
1532/// puts a part's forward edge, middle or aft edge on the position (from the top, after a part or
1533/// absolute; the middle; the bottom) and gives a row the length from the first button's forward
1534/// edge to the last one's aft edge, so the offset moves by the difference.
1535fn centered_on_its_position(position: Position, button: &RailButton) -> Position {
1536    let radius_m = 0.5 * button.outer_diameter_m;
1537    let row_m = button.spacing_m * f64::from(button.count.saturating_sub(1));
1538    match position {
1539        Position::Top { aft_offset_m } => Position::Top {
1540            aft_offset_m: aft_offset_m - radius_m,
1541        },
1542        Position::Middle { aft_offset_m } => Position::Middle {
1543            aft_offset_m: aft_offset_m + 0.5 * row_m,
1544        },
1545        Position::Bottom { aft_offset_m } => Position::Bottom {
1546            aft_offset_m: aft_offset_m + radius_m + row_m,
1547        },
1548        Position::After { aft_offset_m } => Position::After {
1549            aft_offset_m: aft_offset_m - radius_m,
1550        },
1551        Position::Absolute { station_m } => Position::Absolute {
1552            station_m: station_m - radius_m,
1553        },
1554    }
1555}
1556
1557/// The surface finish, as a roughness height.
1558///
1559/// OpenRocket writes one of five words. What each is worth in micrometers is not in the file
1560/// format documentation; the numbers below are from the program's author, on The Rocketry Forum
1561/// (thread "Open Rocket Finishes", post #6, 22 August 2013):
1562///
1563/// | word | OpenRocket's name for it | roughness |
1564/// | --- | --- | --- |
1565/// | `rough` | Rough | 500 µm |
1566/// | `unfinished` | Unfinished | 150 µm |
1567/// | `normal` | Regular paint | 60 µm |
1568/// | `smooth` | Smooth paint | 20 µm |
1569/// | `polished` | Polished | 2 µm |
1570///
1571/// The default, `normal`, is confirmed twice over: the [OpenRocket technical documentation][doc]
1572/// section 6 says "the 'regular paint' finish was selected, which corresponds to an average
1573/// surface roughness of 60 µm", and the user guide's body-tube dialog reads "Regular paint
1574/// (2.36 mil)", which is 59.9 µm. The guide's [`.ork` page][guide] has the rest, including what is
1575/// not settled about `polished`.
1576///
1577/// [doc]: https://openrocket.sourceforge.net/techdoc.pdf
1578/// [guide]: https://nrdptel.github.io/hpr-sim/format/ork.html
1579pub(super) fn finish(values: &mut Values<'_>) -> Option<Finish> {
1580    let word = values.word(&["finish"])?;
1581    let roughness_m = match word.as_str() {
1582        "rough" => 500e-6,
1583        "unfinished" => 150e-6,
1584        "normal" => 60e-6,
1585        "smooth" => 20e-6,
1586        "polished" => 2e-6,
1587        other => {
1588            let other = other.to_owned();
1589            values.warn_at(
1590                WarningKind::Unusual,
1591                format!(
1592                    "`{other}` is not a surface finish this reader has a roughness for; the part \
1593                     took hpr's default"
1594                ),
1595            );
1596            values.forget(&["finish"]);
1597            return None;
1598        }
1599    };
1600    Some(Finish::Custom { roughness_m })
1601}
1602
1603/// A part's roll angle around the body, in radians.
1604///
1605/// **`.ork` angles are degrees.** Nothing in the file says so, and every length beside them is in
1606/// meters, so reading one as radians is an easy mistake: `<angleoffset>180</angleoffset>` would be
1607/// more than twenty-eight turns instead of half of one. The corpus settles it. Of the 993 angles
1608/// written in it, 188 are not zero, and **178 of those are larger than 2π**: more than a whole
1609/// turn, which no component is written at. The values themselves are 180, 90, 45, 30 and 120, with
1610/// float dust (`119.99999999999999`) from a conversion that went through radians and back.
1611/// `cargo xtask ork` prints all three counts.
1612///
1613/// OpenRocket writes the angle under a newer name, `angleoffset`, and an older one: `rotation` on
1614/// a fin set, `radialdirection` on everything else. Those are **not** read as one tag by
1615/// [`Values::element`]: the newer name carries a `method` attribute that the older never does, and
1616/// [ADR-052][adr] left the pair alone for want of a source saying what the older name's frame is.
1617/// What settles it here is narrower than that question and enough for it: on the 95 fin sets and
1618/// 26 other parts of the reference corpus that carry both names, the two agree on the **number**
1619/// every time, so which one is read cannot change an angle. The frames (`relative` to the parent
1620/// and `fixed` in the rocket) are the same angle for every parent this reader builds, because all
1621/// of them sit on the rocket's own axis. A pod set hangs from a body tube, on the axis too, and
1622/// OpenRocket 24.12 places its pods alike for `relative`, `fixed` and `mirror_xy` ([`pod_set`]).
1623///
1624/// **Which way the angle turns is assumed, not sourced.** `docs/physics/frames.md` measures a roll
1625/// angle from `x_B` toward `y_B`, right-handed about `+z_B`, which points at the nose; OpenRocket's
1626/// technical documentation (§3.1.4) puts its own `+x` along the centerline pointing *aft* and
1627/// leaves the other two axes unstated. A right-handed angle about an aft-pointing axis is a
1628/// left-handed one about `+z_B`, so if OpenRocket means that, every angle read here is mirrored:
1629/// a mass object at 90° sits on the other side, and a canted fin set rolls the other way. Nothing
1630/// in the corpus can settle it, because a mirrored design is still a valid design; one asymmetric
1631/// design through the OpenRocket oracle (M2.2) will. Until then this reader takes the number
1632/// unchanged, and the guide lists it among the readings that are not settled.
1633///
1634/// [adr]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md
1635fn roll_angle(values: &mut Values<'_>) -> f64 {
1636    let newer = values.number(&["angleoffset"]);
1637    let older = values
1638        .number(&["rotation"])
1639        .or_else(|| values.number(&["radialdirection"]));
1640    if let (Some(newer), Some(older)) = (newer, older)
1641        && newer != older
1642    {
1643        values.warn_at(
1644            WarningKind::Dropped,
1645            format!(
1646                "`angleoffset` says `{newer}` and the older name says `{older}`; they are two \
1647                 names for one angle, so `angleoffset` was taken"
1648            ),
1649        );
1650        values.forget(&["rotation", "radialdirection"]);
1651    }
1652    newer.or(older).unwrap_or_default().to_radians()
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657    use super::*;
1658    use hpr_design::Material;
1659
1660    /// Each of OpenRocket's fourteen cluster patterns, written here as the figure it is, is the
1661    /// list of points OpenRocket 24.12 gives for it, in its order, to 1e-15; and there is no other
1662    /// (`validation/fixtures/ork/openrocket-clusters.json`).
1663    #[test]
1664    fn cluster_patterns_are_openrocket_s_points() {
1665        let text = include_str!("../../../../validation/fixtures/ork/openrocket-clusters.json");
1666        let record: serde_json::Value = serde_json::from_str(text).unwrap();
1667        let patterns = record["patterns"].as_object().unwrap();
1668        assert_eq!(patterns.len(), 14);
1669        for (name, pattern) in patterns {
1670            let theirs: Vec<f64> = pattern["points"]
1671                .as_array()
1672                .unwrap()
1673                .iter()
1674                .map(|v| v.as_f64().unwrap())
1675                .collect();
1676            let ours = cluster_pattern(name).unwrap_or_else(|| panic!("no pattern {name}"));
1677            assert_eq!(pattern["count"].as_u64(), Some(ours.len() as u64), "{name}");
1678            assert_eq!(theirs.len(), 2 * ours.len(), "{name}");
1679            for (k, [x, y]) in ours.iter().enumerate() {
1680                let apart = (x - theirs[2 * k]).hypot(y - theirs[2 * k + 1]);
1681                assert!(apart <= 1e-15, "{name} point {k}: {x}, {y}");
1682            }
1683        }
1684        assert_eq!(cluster_pattern("4-square"), None);
1685    }
1686
1687    /// A row of two 10 mm buttons 0.1 m apart: its first button's center must land where OpenRocket
1688    /// puts it, a part of no length placed by the same words, whichever end the offset is from.
1689    /// hpr's row runs from the first button's forward edge, 5 mm ahead of its center, and is
1690    /// 0.11 m long.
1691    #[test]
1692    fn a_rail_button_row_is_placed_by_its_first_center() {
1693        let button = RailButton {
1694            outer_diameter_m: 0.01,
1695            inner_diameter_m: 0.006,
1696            height_m: 0.008,
1697            base_height_m: 0.002,
1698            flange_height_m: 0.002,
1699            screw_height_m: 0.0,
1700            angle_rad: 0.0,
1701            count: 2,
1702            spacing_m: 0.1,
1703            material: Material::bulk("Probe", 1000.0),
1704        };
1705        let cases = [
1706            // top 0.1: the forward edge 5 mm ahead.
1707            (
1708                Position::Top { aft_offset_m: 0.1 },
1709                Position::Top {
1710                    aft_offset_m: 0.095,
1711                },
1712            ),
1713            // middle 0: the first center on the middle, so the row's middle 0.05 m aft of it.
1714            (
1715                Position::Middle { aft_offset_m: 0.0 },
1716                Position::Middle { aft_offset_m: 0.05 },
1717            ),
1718            // bottom −0.1: the first center 0.1 m above the end, the row's aft edge 5 mm past
1719            // the last center, which is 0.1 m further aft.
1720            (
1721                Position::Bottom { aft_offset_m: -0.1 },
1722                Position::Bottom {
1723                    aft_offset_m: 0.005,
1724                },
1725            ),
1726            (
1727                Position::After { aft_offset_m: 0.02 },
1728                Position::After {
1729                    aft_offset_m: 0.015,
1730                },
1731            ),
1732            (
1733                Position::Absolute { station_m: 1.0 },
1734                Position::Absolute { station_m: 0.995 },
1735            ),
1736        ];
1737        for (read, placed) in cases {
1738            let found = centered_on_its_position(read, &button);
1739            let offset = |position: &Position| match *position {
1740                Position::Top { aft_offset_m }
1741                | Position::Middle { aft_offset_m }
1742                | Position::Bottom { aft_offset_m }
1743                | Position::After { aft_offset_m } => aft_offset_m,
1744                Position::Absolute { station_m } => station_m,
1745            };
1746            assert_eq!(
1747                std::mem::discriminant(&found),
1748                std::mem::discriminant(&placed),
1749                "{read:?}"
1750            );
1751            assert!(
1752                (offset(&found) - offset(&placed)).abs() < 1e-15,
1753                "{read:?}: {found:?}"
1754            );
1755        }
1756    }
1757}