Skip to main content

hpr_io/ork/
component.rs

1//! The spine of a design: its stages and the body components stacked inside them.
2//!
3//! A `.ork` design is a tree. Its trunk is the **spine**: the stages, and inside each of them the
4//! nose cones, body tubes and transitions that stack end to end along the axis. Everything else
5//! hangs off that trunk and is read by [`super::attached`]: the tubes and rings inside the body,
6//! the fins and lugs on it, the recovery gear.
7//!
8//! What this module does is turn the spine into [`hpr_design`] types: a [`Rocket`] of [`Stage`]s of
9//! [`Component`]s, each of them carrying whatever [`super::attached`] read inside and on it. It
10//! resolves nothing itself. Where OpenRocket wrote `auto`, the component carries an
11//! [`AutoDimension`] and [`Rocket::layout`] works the radius out from the neighbours, which is the
12//! one place that rule lives. The one exception is a radius that rule cannot reach (a chain of
13//! automatic radii with no fixed radius anywhere along it), which takes OpenRocket's default,
14//! [`OPENROCKET_DEFAULT_RADIUS_M`], because the design holds no other number for it.
15//!
16//! [roadmap]: https://github.com/nrdptel/hpr-sim/blob/main/docs/ROADMAP.md
17
18use hpr_design::parts::{BodyTube, NoseCone, Shoulder, Transition};
19use hpr_design::shapes::NoseShape;
20use hpr_design::solids::Wall;
21use hpr_design::tree::{
22    AutoDimension, Component, DragOverride, Overrides, Part, ReferenceDiameter, Rocket, Stage,
23};
24use hpr_design::{Material, MotorMount};
25
26use super::attached::{self, finish};
27use super::document::{Document, Element};
28use super::motors::{self, MountRead};
29use super::recovery::{self, DeviceRead, SeparationRead};
30use super::value::{OVERRIDE_FLAGS, Values};
31use super::warning::{Imported, Warning, WarningKind};
32
33/// The body tags this milestone reads. Anything else in a `<subcomponents>` is counted and left.
34pub(super) const BODY_TAGS: [&str; 3] = ["nosecone", "bodytube", "transition"];
35
36/// A filled tube whose automatic radius cached a number, read as solid to that number.
37const FILLED_TO_CACHE: &str = "a filled tube whose radius is automatic; it was read as solid to \
38                               the radius OpenRocket last worked out, which may be stale";
39
40/// A filled tube whose automatic radius cached nothing, so it has no radius to be solid to.
41const FILLED_TO_NOTHING: &str = "a filled tube whose radius is automatic and nothing cached; it \
42                                 carries no mass, because there is no radius to fill until the \
43                                 layout resolves one";
44
45/// What a body component was read from: where it is in the file, and for a body tube the wall as
46/// the file wrote it, before any radius was known to judge it against.
47struct BodyRead {
48    at: String,
49    wall: Option<Wall>,
50}
51
52/// The radius OpenRocket gives an automatic body radius that has no fixed radius anywhere along its
53/// chain to take, in meters: its **default radius**, 25 mm.
54///
55/// OpenRocket's maintainers write that such a radius is "the default radius"
56/// ([openrocket#1988](https://github.com/openrocket/openrocket/issues/1988#issuecomment-1397654629))
57/// and that a tube left with nothing to take "reverts to default diameter"
58/// ([#1992](https://github.com/openrocket/openrocket/issues/1992)); a user guesses that default at
59/// "1.969 in" of diameter ([#871](https://github.com/openrocket/openrocket/issues/871)), which is
60/// 50.0 mm. No document states the number, so it was measured: OpenRocket 24.12, run on fifteen
61/// small designs by `validation/oracles/openrocket/automatic_radius.py`, settles every such tube,
62/// lone nose cone and lone transition at 0.025 m and ignores any number cached after `auto`
63/// (`validation/fixtures/ork/openrocket-automatic-radius.json`, [ADR-054][adr-054]).
64///
65/// hpr departs from OpenRocket in one place, on purpose: where a nose cone's base or a transition's
66/// end looks at another automatic radius, OpenRocket 24.12 settles on −1 m, which no geometry can
67/// take. hpr gives it this default too, so the chain is one radius end to end.
68///
69/// [adr-054]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-054-an-automatic-radius-with-nothing-to-take-is-openrockets-default-and-a-rocket-with-no-stage-or-component-holds-no-design-2026-09-20
70pub const OPENROCKET_DEFAULT_RADIUS_M: f64 = 0.025;
71
72/// Reads a design document into a [`Rocket`]: its stages, the body components stacked in them, and
73/// the parts on and inside each of those.
74///
75/// Never fails: a tag it cannot read is left out with a [`Warning`], so a design written by another
76/// program still opens. The result is a [`Rocket`] whose body components are in file order, forward
77/// to aft, with automatic dimensions **marked rather than filled in**: [`Rocket::layout`] resolves
78/// them, and is where a part's mass and station come from.
79///
80/// A body tube or inner tube that holds a motor is marked as a motor mount, but the motors
81/// themselves are not read here, and [`Rocket::configurations`] is left empty:
82/// [`super::design`] reads them. A `<motormount>` it cannot read still warns here.
83///
84/// ```
85/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
86/// let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
87/// <openrocket version="1.10" creator="OpenRocket 24.12">
88///   <rocket><name>Sounder</name><subcomponents><stage><name>Sustainer</name><id>s</id>
89///     <subcomponents>
90///       <nosecone><name>Nose</name><id>nose</id><finish>smooth</finish>
91///         <material type="bulk" density="680.0">Cardboard</material>
92///         <length>0.3</length><thickness>0.002</thickness>
93///         <shape>ogive</shape><shapeparameter>1.0</shapeparameter>
94///         <aftradius>0.05</aftradius></nosecone>
95///       <bodytube><name>Tube</name><id>tube</id>
96///         <material type="bulk" density="680.0">Cardboard</material>
97///         <length>0.6</length><thickness>0.002</thickness><radius>0.05</radius>
98///         <subcomponents>
99///           <centeringring><name>Ring</name><id>ring</id>
100///             <material type="bulk" density="680.0">Plywood</material>
101///             <axialoffset method="bottom">0.0</axialoffset><length>0.005</length>
102///             <outerradius>auto</outerradius><innerradius>0.019</innerradius></centeringring>
103///         </subcomponents></bodytube>
104///     </subcomponents></stage></subcomponents></rocket>
105/// </openrocket>"#;
106///
107/// let read = hpr_io::ork::read(xml)?;
108/// let design = hpr_io::ork::rocket(&read.value.document);
109///
110/// // Anything the reader could not take at face value travels with the result. Nothing here did.
111/// assert!(design.warnings.is_empty(), "{:?}", design.warnings);
112///
113/// // The ring's outer radius says `auto`, so the design carries the dimension, not a number...
114/// use hpr_design::AutoDimension;
115/// let ring = &design.value.stages[0].components[1].children[0];
116/// assert!(ring.auto.contains(&AutoDimension::OuterRadius));
117///
118/// // ...and the layout works it out: the bore of the tube the ring sits in, 0.05 - 0.002.
119/// let layout = design.value.layout()?;
120/// let (_, placed) = layout.find("ring").expect("the ring");
121/// let hpr_design::Part::CenteringRing(ring) = &placed.part else { panic!("a ring") };
122/// assert!((ring.outer_radius_m - 0.048).abs() < 1e-12);
123/// assert!(placed.own.mass_kg > 0.0);
124/// # Ok(())
125/// # }
126/// ```
127pub fn rocket(document: &Document) -> Imported<Rocket> {
128    walk(document).0
129}
130
131/// [`rocket`], and every motor mount, recovery device and stage separation it read, for
132/// [`super::motors::read`] and [`super::recovery::read`].
133pub(super) fn walk(document: &Document) -> (Imported<Rocket>, Walked) {
134    let mut warnings = Vec::new();
135    let mut rocket = Rocket {
136        name: String::new(),
137        stages: Vec::new(),
138        reference_diameter: ReferenceDiameter::default(),
139        configurations: Vec::new(),
140    };
141    let Some(element) = document.root.child("rocket") else {
142        warnings.push(Warning::new(
143            "openrocket",
144            WarningKind::Skipped,
145            "no `rocket` element, so the document holds no design".to_owned(),
146        ));
147        return (
148            Imported {
149                value: rocket,
150                warnings,
151            },
152            Walked::default(),
153        );
154    };
155    let at = "openrocket/rocket";
156    rocket.name = Values::new(element, at, &mut warnings)
157        .word(&["name"])
158        .unwrap_or_default();
159    rocket.reference_diameter = reference_diameter(element, at, &mut warnings);
160    if subcomponents(element).next().is_none() {
161        // A document can carry a rocket's name and stored results and nothing to build: Debrief's
162        // synthesized demonstration file does. There is no design in it to lay out.
163        warnings.push(Warning::new(
164            at,
165            WarningKind::Unusual,
166            "the `rocket` holds no stage or component of any kind, so the document holds no design"
167                .to_owned(),
168        ));
169    }
170
171    let mut ids = Ids {
172        parallel_stages_read: subcomponents(element)
173            .filter(|child| child.name == "stage")
174            .count()
175            == 1,
176        ..Ids::default()
177    };
178    let mut skipped: Vec<String> = Vec::new();
179    let mut reads: Vec<Vec<BodyRead>> = Vec::new();
180    for (index, stage_element) in subcomponents(element).enumerate() {
181        if stage_element.name != "stage" {
182            skipped.push(stage_element.name.clone());
183            continue;
184        }
185        let mut read = Vec::new();
186        rocket.stages.push(stage(
187            stage_element,
188            index,
189            &mut ids,
190            &mut read,
191            &mut skipped,
192            &mut warnings,
193        ));
194        reads.push(read);
195        // Its parallel stages follow it, as OpenRocket numbers them; their body components
196        // resolve among themselves, as a pod's do, so `default_radii` has nothing to read there.
197        for parallel in std::mem::take(&mut ids.parallel) {
198            rocket.stages.push(parallel);
199            reads.push(Vec::new());
200        }
201    }
202    if let Some(note) = tally(&skipped) {
203        warnings.push(Warning::new(
204            at,
205            WarningKind::Skipped,
206            format!(
207                "{note} were left out: tags hpr does not read where they are written (a \
208                 parallel stage is read only inside a body tube)"
209            ),
210        ));
211    }
212    default_radii(&mut rocket, &reads, &mut warnings);
213    (
214        Imported {
215            value: rocket,
216            warnings,
217        },
218        Walked {
219            mounts: ids.mounts,
220            devices: ids.devices,
221            separations: ids.separations,
222            read: ids.read,
223        },
224    )
225}
226
227/// Gives every automatic body radius that [`Rocket::unresolvable_body_radii`] lists
228/// [`OPENROCKET_DEFAULT_RADIUS_M`], as a fixed radius, with a warning at its tag naming the radius.
229///
230/// A body tube filled that way has its wall judged again, now there is a radius to judge it
231/// against, by the rule a stated radius gets: `filled`, or a wall at least as thick as the radius,
232/// is solid. The warnings that said the radius was not known yet are withdrawn, because it is.
233fn default_radii(rocket: &mut Rocket, reads: &[Vec<BodyRead>], warnings: &mut Vec<Warning>) {
234    let radius_m = OPENROCKET_DEFAULT_RADIUS_M;
235    for filled in rocket.fill_unresolvable_body_radii(radius_m) {
236        let Some(read) = reads
237            .get(filled.stage)
238            .and_then(|stage| stage.get(filled.component))
239        else {
240            continue;
241        };
242        let component = &mut rocket.stages[filled.stage].components[filled.component];
243        if let Part::BodyTube(tube) = &mut component.part {
244            match read.wall {
245                Some(Wall::Filled {}) => tube.thickness_m = radius_m,
246                Some(Wall::Shell { thickness_m }) if thickness_m >= radius_m => {
247                    tube.thickness_m = radius_m;
248                }
249                _ => {}
250            }
251            warnings.retain(|warning| {
252                warning.at != read.at
253                    || (warning.message != FILLED_TO_CACHE && warning.message != FILLED_TO_NOTHING)
254            });
255        }
256        warnings.push(Warning::new(
257            read.at.clone(),
258            WarningKind::Unusual,
259            format!(
260                "an automatic radius with no fixed radius anywhere along its chain to take, its \
261                 `{}`; it was given {:.0} mm, OpenRocket's default radius for a tube with nothing \
262                 to take",
263                radius_tag(filled.dimension),
264                radius_m * 1e3
265            ),
266        ));
267    }
268}
269
270/// The tag a body component's radius is written under: a nose cone's base is its `aftradius`.
271fn radius_tag(dimension: AutoDimension) -> &'static str {
272    match dimension {
273        AutoDimension::OuterRadius => "radius",
274        AutoDimension::ForeRadius => "foreradius",
275        AutoDimension::BaseRadius | AutoDimension::AftRadius => "aftradius",
276        other => other.name(),
277    }
278}
279
280/// Reads one `<stage>`, and everything stacked inside it. What each body component was read from
281/// goes into `reads`, in the stage's order, for [`default_radii`].
282fn stage(
283    element: &Element,
284    index: usize,
285    ids: &mut Ids,
286    reads: &mut Vec<BodyRead>,
287    skipped: &mut Vec<String>,
288    warnings: &mut Vec<Warning>,
289) -> Stage {
290    let at = format!("openrocket/rocket/stage[{index}]");
291    let mut values = Values::new(element, &at, warnings);
292    let name = values.word(&["name"]).unwrap_or_default();
293    let (overrides, _, drag_override) = overrides(&mut values);
294    let id = ids.take(&mut Values::new(element, &at, warnings), "stage");
295    if let Some(separation) = recovery::separation(element, &at, warnings) {
296        ids.separations.push((id.clone(), separation));
297    }
298    ids.read.insert(at.clone());
299    let mut components = Vec::new();
300    // The index is part of the path so that a warning can be traced back to one part of the 188
301    // body tubes in the reference library, the way a stage's already could (issue #132).
302    for (index, child) in subcomponents(element).enumerate() {
303        if BODY_TAGS.contains(&child.name.as_str()) {
304            let at = format!("{at}/{}[{index}]", child.name);
305            let (component, wall) = body(child, &at, ids, skipped, warnings);
306            reads.push(BodyRead { at, wall });
307            components.push(component);
308        } else {
309            skipped.push(child.name.clone());
310        }
311    }
312    Stage {
313        id,
314        name,
315        components,
316        overrides,
317        drag_override,
318        parallel: None,
319    }
320}
321
322/// Reads one body component, and everything on and inside it; for a body tube, also the wall as
323/// the file wrote it. A pod's body components are read here too ([`attached::children`]).
324pub(super) fn body(
325    element: &Element,
326    at: &str,
327    ids: &mut Ids,
328    skipped: &mut Vec<String>,
329    warnings: &mut Vec<Warning>,
330) -> (Component, Option<Wall>) {
331    let mut auto = Vec::new();
332    let mut values = Values::new(element, at, warnings);
333    let name = values.word(&["name"]).unwrap_or_default();
334    let (overrides, overrides_include_children, drag_override) = overrides(&mut values);
335    let finish = finish(&mut values);
336    let (part, wall) = match element.name.as_str() {
337        "nosecone" => (nose_cone(element, at, &mut auto, warnings), None),
338        "bodytube" => {
339            let (part, wall) = body_tube(element, at, &mut auto, warnings);
340            (part, Some(wall))
341        }
342        _ => (transition(element, at, &mut auto, warnings), None),
343    };
344    let hung = ids.parallel.len();
345    let children = attached::children(element, &part, &auto, at, ids, skipped, warnings);
346    let mount = match part {
347        Part::BodyTube(_) => motors::mount(element, at, warnings),
348        _ => None,
349    };
350    let id = ids.take(
351        &mut Values::new(element, at, warnings),
352        &element.name.clone(),
353    );
354    let motor_mount = mount.map(|mount| {
355        let spec = MotorMount {
356            overhang_m: mount.overhang_m,
357        };
358        ids.mounts.push((id.clone(), mount));
359        spec
360    });
361    // The parallel stages read among this tube's children hang on it.
362    for stage in &mut ids.parallel[hung..] {
363        if let Some(parallel) = &mut stage.parallel {
364            parallel.on.clone_from(&id);
365        }
366    }
367    ids.read.insert(at.to_owned());
368    let component = Component {
369        id,
370        name,
371        part,
372        position: None,
373        auto,
374        motor_mount,
375        finish,
376        overrides,
377        overrides_include_children,
378        drag_override,
379        children,
380    };
381    (component, wall)
382}
383
384fn nose_cone(
385    element: &Element,
386    at: &str,
387    auto: &mut Vec<AutoDimension>,
388    warnings: &mut Vec<Warning>,
389) -> Part {
390    let mut values = Values::new(element, at, warnings);
391    let length_m = values.number(&["length"]).unwrap_or_default();
392    // A flipped nose cone is a tail cone: its base is forward, so its radius is the fore radius of
393    // a transition that ends in a point, and its shoulder is on that base. With one end a point, a
394    // transition's clipped and unclipped profiles are both the whole nose shape, mirrored
395    // (`hpr_design::shapes`), so the solid is the nose cone's, turned end for end.
396    if values.flag(&["isflipped"]) == Some(true) {
397        let (stated_m, fore_radius_m) =
398            stated_radius(&mut values, &["aftradius"], AutoDimension::ForeRadius, auto);
399        return Part::Transition(Transition {
400            wall: wall(&mut values, stated_m),
401            shape: shape(&mut values),
402            clipped: false,
403            length_m,
404            fore_radius_m,
405            aft_radius_m: 0.0,
406            fore_shoulder: shoulder(&mut values, "aft", AutoDimension::ForeShoulderRadius, auto),
407            aft_shoulder: None,
408            material: material(&mut values, &["material"], "bulk"),
409        });
410    }
411    let (stated_m, base_radius_m) =
412        stated_radius(&mut values, &["aftradius"], AutoDimension::BaseRadius, auto);
413    let wall = wall(&mut values, stated_m);
414    let shape = shape(&mut values);
415    let shoulder = shoulder(&mut values, "aft", AutoDimension::ShoulderRadius, auto);
416    Part::NoseCone(NoseCone {
417        shape,
418        length_m,
419        base_radius_m,
420        wall,
421        shoulder,
422        material: material(&mut values, &["material"], "bulk"),
423    })
424}
425
426fn body_tube(
427    element: &Element,
428    at: &str,
429    auto: &mut Vec<AutoDimension>,
430    warnings: &mut Vec<Warning>,
431) -> (Part, Wall) {
432    let mut values = Values::new(element, at, warnings);
433    let length_m = values.number(&["length"]).unwrap_or_default();
434    let (stated_m, outer_radius_m) =
435        stated_radius(&mut values, &["radius"], AutoDimension::OuterRadius, auto);
436    // A body tube is a wall, not a solid of revolution, so `filled` has to be said as a wall as
437    // thick as the tube. With an automatic radius there is no such number yet: the tube is read as
438    // the wall it caches, and says so.
439    let read = wall(&mut values, stated_m);
440    let thickness_m = match (read, stated_m) {
441        (Wall::Filled {}, Some(radius_m)) => radius_m,
442        (Wall::Filled {}, None) if outer_radius_m > 0.0 => {
443            values.warn_at(WarningKind::Dropped, FILLED_TO_CACHE);
444            outer_radius_m
445        }
446        // Nothing cached either, so there is no number that means "solid" until the layout
447        // resolves one. The tube still has to stand in the stack, so it stands as a wall of
448        // nothing, and that is said as loudly as a part left out, because a solid tube read as an
449        // empty one is a mass quietly missing rather than a design that fails (issue #130).
450        (Wall::Filled {}, None) => {
451            values.warn_at(WarningKind::Skipped, FILLED_TO_NOTHING);
452            // The design holds no `filled`, so the tag is kept for an export to write back.
453            values.forget(&["thickness"]);
454            0.0
455        }
456        (Wall::Shell { thickness_m }, _) => thickness_m,
457    };
458    let part = Part::BodyTube(BodyTube {
459        length_m,
460        outer_radius_m,
461        thickness_m,
462        material: material(&mut values, &["material"], "bulk"),
463    });
464    (part, read)
465}
466
467fn transition(
468    element: &Element,
469    at: &str,
470    auto: &mut Vec<AutoDimension>,
471    warnings: &mut Vec<Warning>,
472) -> Part {
473    let mut values = Values::new(element, at, warnings);
474    let length_m = values.number(&["length"]).unwrap_or_default();
475    let (stated_fore_m, fore_radius_m) = stated_radius(
476        &mut values,
477        &["foreradius"],
478        AutoDimension::ForeRadius,
479        auto,
480    );
481    let (stated_aft_m, aft_radius_m) =
482        stated_radius(&mut values, &["aftradius"], AutoDimension::AftRadius, auto);
483    // Either end may be automatic; a wall is only too thick when it is thicker than both ends that
484    // are known.
485    let known = match (stated_fore_m, stated_aft_m) {
486        (Some(fore_m), Some(aft_m)) => Some(fore_m.max(aft_m)),
487        _ => None,
488    };
489    let wall = wall(&mut values, known);
490    let shape = shape(&mut values);
491    // Only 1 of the 21 transitions in the reference corpus states `shapeclipped`, so the default
492    // matters and no document this project may read states it. Clipped is taken as the default
493    // because it is the shape a transition between two radii needs to reach both of them; the
494    // OpenRocket oracle (M2.2) is what will settle it.
495    let clipped = values.flag(&["shapeclipped"]).unwrap_or(true);
496    Part::Transition(Transition {
497        shape,
498        clipped,
499        length_m,
500        fore_radius_m,
501        aft_radius_m,
502        wall,
503        fore_shoulder: shoulder(&mut values, "fore", AutoDimension::ForeShoulderRadius, auto),
504        aft_shoulder: shoulder(&mut values, "aft", AutoDimension::AftShoulderRadius, auto),
505        material: material(&mut values, &["material"], "bulk"),
506    })
507}
508
509/// The radius, and what it is worth to a reader before the layout resolves it: `None` when the file
510/// says `auto`, whether or not OpenRocket cached a number with it, because the cached number is the
511/// neighbour it last had and may be stale.
512pub(super) fn stated_radius(
513    values: &mut Values<'_>,
514    names: &[&str],
515    dimension: AutoDimension,
516    auto: &mut Vec<AutoDimension>,
517) -> (Option<f64>, f64) {
518    let Some(read) = values.dimension(names) else {
519        // A tag that is there but unreadable has already said so. A tag that is not there at all
520        // is read as zero, which for a radius is a part with no width: a transition with no
521        // `foreradius` lays out as a cone growing from a point, and says nothing unless it says
522        // this (issue #131).
523        if values.element(names).is_none() {
524            let name = names.first().copied().unwrap_or("dimension").to_owned();
525            values.warn_at(
526                WarningKind::Dropped,
527                format!("no `{name}`, so it was read as zero"),
528            );
529        }
530        return (None, 0.0);
531    };
532    if read.is_automatic() {
533        auto.push(dimension);
534        return (None, read.value().unwrap_or_default());
535    }
536    let value = read.value().unwrap_or_default();
537    (Some(value), value)
538}
539
540/// `<thickness>filled</thickness>` is a solid part; anything else is a wall of that thickness.
541///
542/// `outer_radius_m` is the radius the wall sits in, and is `None` when that radius is automatic and
543/// so is not known yet. A wall at least as thick as a known radius is the part filled. It must not
544/// be judged against an unknown radius: the stated thickness would be thrown away whenever the
545/// radius was automatic, which is half of [Loft lesson L61][lessons]: a stated wall dropped
546/// because the outer radius was `auto`.
547///
548/// Two readings are OpenRocket 24.12's, measured on probe designs by
549/// `validation/oracles/openrocket/conventions.py` ([ADR-061][adr-061]):
550///
551/// - a wall of no thickness is a surface with no wall: the part keeps its shape and weighs
552///   nothing, where a filled part is written `filled`;
553/// - a part that writes no thickness at all has OpenRocket's 2 mm wall
554///   ([`DEFAULT_WALL_M`]), whatever its radius.
555///
556/// [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
557/// [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
558fn wall(values: &mut Values<'_>, outer_radius_m: Option<f64>) -> Wall {
559    if values.word(&["thickness"]).as_deref() == Some("filled") {
560        return Wall::Filled {};
561    }
562    match (values.number(&["thickness"]), outer_radius_m) {
563        (Some(thickness_m), Some(radius_m)) if thickness_m >= radius_m => Wall::Filled {},
564        // OpenRocket's 2 mm, in a part no wider than it, is taken to fill the part, as a stated wall
565        // that thick does: an assumption, since no probe is that narrow and no file writes one.
566        (None, Some(radius_m)) if DEFAULT_WALL_M >= radius_m => Wall::Filled {},
567        (Some(thickness_m), _) if thickness_m > 0.0 => Wall::Shell { thickness_m },
568        (Some(0.0), _) => Wall::Shell { thickness_m: 0.0 },
569        (Some(thickness_m), _) => {
570            values.warn_at(
571                WarningKind::Dropped,
572                format!("a wall {thickness_m} m thick is no wall; it was read as none"),
573            );
574            values.forget(&["thickness"]);
575            Wall::Shell { thickness_m: 0.0 }
576        }
577        (None, _) => Wall::Shell {
578            thickness_m: DEFAULT_WALL_M,
579        },
580    }
581}
582
583/// The wall OpenRocket 24.12 gives a nose cone, transition or body tube that writes no thickness,
584/// m: a nose cone and a tube at 50 mm and at 30 mm, and a transition, each weigh what a 2 mm wall
585/// gives ([ADR-061][adr-061]).
586///
587/// [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
588const DEFAULT_WALL_M: f64 = 0.002;
589
590/// A shoulder at one end, if the file gives it a length. A zero-length shoulder is no shoulder.
591fn shoulder(
592    values: &mut Values<'_>,
593    end: &str,
594    dimension: AutoDimension,
595    auto: &mut Vec<AutoDimension>,
596) -> Option<Shoulder> {
597    let length_m = values.number(&[&format!("{end}shoulderlength")])?;
598    if length_m <= 0.0 {
599        return None;
600    }
601    let (known_m, outer_radius_m) =
602        stated_radius(values, &[&format!("{end}shoulderradius")], dimension, auto);
603    // A shoulder with no wall, or none written, weighs nothing in OpenRocket 24.12, capped or not
604    // and whether or not the part it hangs from is filled: measured on probe designs (ADR-061).
605    // So it is read as a tube of no wall, which weighs nothing.
606    // The stated wall is clamped to the radius it sits in, and only when that radius is known:
607    // clamping against an automatic one that has not resolved yet would throw the wall away, which
608    // is half of Loft lesson L61 and what issue #130 found still open on a shoulder.
609    let thickness_m = match values.number(&[&format!("{end}shoulderthickness")]) {
610        Some(stated_m) if stated_m > 0.0 => {
611            known_m.map_or(stated_m, |radius_m| stated_m.min(radius_m))
612        }
613        Some(stated_m) if stated_m < 0.0 => {
614            values.warn_at(
615                WarningKind::Dropped,
616                format!("the {end} shoulder's wall is {stated_m} m thick, which is no wall; it was read as none"),
617            );
618            values.forget(&[&format!("{end}shoulderthickness")]);
619            0.0
620        }
621        _ => 0.0,
622    };
623    Some(Shoulder {
624        length_m,
625        outer_radius_m,
626        thickness_m,
627        // A cap is as thick as the shoulder's wall, so a shoulder of no wall has none; and a solid
628        // shoulder has no bore to close, so a cap on one is nothing either: reading it as a cap
629        // asks `hpr-design` for a disc inside a tube that isn't hollow.
630        capped: thickness_m > 0.0
631            && known_m.is_none_or(|radius_m| thickness_m < radius_m)
632            && values
633                .flag(&[&format!("{end}shouldercapped")])
634                .unwrap_or_default(),
635    })
636}
637
638/// The profile shape and its parameter.
639///
640/// OpenRocket's shape parameter is `κ`, defined in [Niskanen's technical documentation][niskanen],
641/// appendix A. For an ogive it is the ratio of a tangent ogive's radius of curvature to this one's,
642/// `κ = ρ_t/ρ` (equation A.3), so `κ = 1` is a tangent ogive and `κ = 0` an infinite radius, which
643/// is a cone. [`NoseShape::Ogive`] states the same shape the other way up, as `ρ/ρ_t`, so the two
644/// are reciprocals. The power series (A.8) and the parabolic series (A.7) use the same `κ` this
645/// crate's [`NoseShape::PowerSeries`] and [`NoseShape::ParabolicSeries`] do, and the Haack series
646/// (A.9) the same `C`.
647///
648/// [niskanen]: https://github.com/nrdptel/hpr-sim/blob/main/docs/format/ork.md
649fn shape(values: &mut Values<'_>) -> NoseShape {
650    let name = values.word(&["shape"]).unwrap_or_default();
651    let parameter = values.number(&["shapeparameter"]);
652    match (name.as_str(), parameter) {
653        ("conical", _) => NoseShape::Conical {},
654        ("ellipsoid", _) => NoseShape::Elliptical {},
655        // κ = 0 is the cone, and the reciprocal below would divide by it.
656        ("ogive", Some(kappa)) if kappa <= 0.0 => NoseShape::Conical {},
657        ("ogive", kappa) => NoseShape::Ogive {
658            radius_ratio: 1.0 / kappa.unwrap_or(1.0),
659        },
660        ("power", Some(exponent)) => NoseShape::PowerSeries { exponent },
661        ("parabolic", Some(parameter)) => NoseShape::ParabolicSeries { parameter },
662        ("haack", parameter) => NoseShape::Haack {
663            parameter: parameter.unwrap_or_default(),
664        },
665        // A power or parabolic series is the shape its parameter says it is, and there is no
666        // sourced default for either: OpenRocket's own is in its source, which this project does
667        // not read. So it is read as a cone and the message says why, rather than blaming the
668        // shape's name (issue #131). No nose in the reference corpus omits it.
669        (shape @ ("power" | "parabolic"), None) => {
670            let shape = shape.to_owned();
671            values.warn_at(
672                WarningKind::Unusual,
673                format!(
674                    "a `{shape}` nose states no shape parameter, and this reader has no sourced \
675                     default for one; it was read as a cone"
676                ),
677            );
678            // The design holds a cone, not the shape named: the name is kept as written.
679            values.forget(&["shape"]);
680            NoseShape::Conical {}
681        }
682        (other, _) => {
683            let other = other.to_owned();
684            values.warn_at(
685                WarningKind::Unusual,
686                format!("`{other}` is not a shape this reader knows; it was read as a cone"),
687            );
688            values.forget(&["shape", "shapeparameter"]);
689            NoseShape::Conical {}
690        }
691    }
692}
693
694/// The material a part is made of. A `.ork` stores the density with the name, so nothing is
695/// looked up.
696///
697/// `want` is the kind of density the part needs: `bulk` for anything solid, `surface` for a
698/// canopy or a streamer, `line` for a shroud line or a shock cord. The file declares a kind of its
699/// own in the `type` attribute, and the number is in that kind's units, so a density declared as
700/// one kind cannot be converted into another: the part is built with the kind it needs and the
701/// disagreement is reported. No material in the reference corpus is declared as a kind its part
702/// does not want.
703///
704/// A part that names no material is made of the one OpenRocket 24.12 gives it, by kind
705/// ([`unnamed_material`]); a rail button's is its own, so it calls [`material_or`].
706pub(super) fn material(values: &mut Values<'_>, names: &[&str], want: &str) -> Material {
707    material_or(values, names, want, unnamed_material(want))
708}
709
710/// The material OpenRocket 24.12 gives a part that names none, by the kind of density it needs:
711/// cardboard, 680 kg/m³, for a solid part; ripstop nylon, 0.067 kg/m², for a canopy or a streamer;
712/// and a 2 mm elastic cord, 0.0018 kg/m, for shroud lines and a shock cord. Each was read from a
713/// probe part of each kind through OpenRocket's public `getMaterial` and `getLineMaterial`
714/// (`validation/oracles/openrocket/conventions.py`, [ADR-061][adr-061]).
715///
716/// [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
717fn unnamed_material(want: &str) -> (&'static str, f64) {
718    match want {
719        "surface" => ("Ripstop nylon", 0.067),
720        "line" => ("Elastic cord (round 2 mm, 1/16 in)", 0.0018),
721        _ => ("Cardboard", 680.0),
722    }
723}
724
725/// The material OpenRocket 24.12 gives a rail button that names none: Delrin, 1,420 kg/m³, measured
726/// as [`unnamed_material`]'s are.
727pub(super) const UNNAMED_RAIL_BUTTON: (&str, f64) = ("Delrin", 1420.0);
728
729/// [`material`], with the material a part that names none is made of.
730pub(super) fn material_or(
731    values: &mut Values<'_>,
732    names: &[&str],
733    want: &str,
734    (unnamed, unnamed_kg): (&str, f64),
735) -> Material {
736    let named = |name: &str, kg: f64| match want {
737        "surface" => Material::surface(name, kg),
738        "line" => Material::line(name, kg),
739        _ => Material::bulk(name, kg),
740    };
741    let Some(element) = values.element(names) else {
742        return named(unnamed, unnamed_kg);
743    };
744    let name = element.text().trim().to_owned();
745    if let Some(declared) = element.attribute("type")
746        && declared != want
747    {
748        let declared = declared.to_owned();
749        values.warn_at(
750            WarningKind::Dropped,
751            format!(
752                "the material `{name}` is declared `{declared}` where a `{want}` density is \
753                 needed; its number was taken as a `{want}` one"
754            ),
755        );
756        // The design holds the kind the part needs, so the file's own is kept as written.
757        super::reads::forget_attribute(element, "type");
758    }
759    let density = element
760        .attribute("density")
761        .and_then(|text| text.parse::<f64>().ok())
762        .filter(|density| density.is_finite() && *density >= 0.0);
763    match density {
764        Some(kg) => named(&name, kg),
765        None => {
766            values.warn_at(
767                WarningKind::Dropped,
768                format!("the material `{name}` states no density; it weighs nothing"),
769            );
770            super::reads::forget_attribute(element, "density");
771            named(&name, 0.0)
772        }
773    }
774}
775
776/// The overrides, as `hpr-design` states them, and whether they cover the parts inside this one.
777///
778/// A `.ork` says "covers the children too" once per quantity and `hpr-design` says it once for the
779/// component. A part that overrides only one quantity takes that quantity's flag; OpenRocket 24.12
780/// applies a center-of-gravity override alone to the whole assembly when its flag says so
781/// (measured, ADR-061). A part that overrides both, with flags that disagree, cannot be said in
782/// `hpr-design`: the mass flag decides, because mass is the quantity the flag is written for (95 of
783/// the 104 in the reference corpus are `overridesubcomponentsmass`), and the disagreement is
784/// reported, and the file's flags are kept as written for an export to put back. The drag override
785/// has its own flag, in `hpr-design` as in the file ([`DragOverride`], ADR-167).
786pub(super) fn overrides(values: &mut Values<'_>) -> (Overrides, bool, Option<DragOverride>) {
787    let read = values.overrides();
788    let drag_override = read.cd.map(|coefficient| DragOverride {
789        coefficient,
790        include_children: read.subcomponents_cd.unwrap_or_default(),
791    });
792    let mass_flag = read.subcomponents_mass.unwrap_or_default();
793    let covers_children = match (read.mass_kg, read.cg_m) {
794        (None, Some(_)) => read.subcomponents_cg.unwrap_or_default(),
795        _ => mass_flag,
796    };
797    if read.mass_kg.is_some()
798        && read.cg_m.is_some()
799        && read.subcomponents_cg.unwrap_or_default() != mass_flag
800    {
801        values.warn_at(
802            WarningKind::Dropped,
803            format!(
804                "the mass override covers the parts inside this one ({mass_flag}) and \
805                 the center-of-gravity override does not agree; hpr states it once, so \
806                 the mass flag was taken"
807            ),
808        );
809        // The flags are kept as written, all together, since their order says which wins.
810        values.forget(&OVERRIDE_FLAGS);
811    }
812    (
813        Overrides {
814            mass_kg: read.mass_kg,
815            cg_aft_m: read.cg_m,
816            cg_xy_m: None,
817            inertia: None,
818        },
819        covers_children,
820        drag_override,
821    )
822}
823
824/// How the reference diameter is chosen. Every design in the reference corpus says `maximum`,
825/// which is OpenRocket's default; anything else is left at that default and reported.
826fn reference_diameter(
827    element: &Element,
828    at: &str,
829    warnings: &mut Vec<Warning>,
830) -> ReferenceDiameter {
831    let word = Values::new(element, at, warnings).word(&["referencetype"]);
832    match word.as_deref() {
833        None | Some("maximum") => ReferenceDiameter::Maximum {},
834        Some(other) => {
835            warnings.push(Warning::new(
836                at,
837                WarningKind::Dropped,
838                format!(
839                    "a reference diameter chosen by `{other}` is not read yet; the widest body \
840                     component was used"
841                ),
842            ));
843            super::reads::forget(element, "referencetype");
844            ReferenceDiameter::Maximum {}
845        }
846    }
847}
848
849/// The children of an element's `<subcomponents>`, in file order.
850pub(super) fn subcomponents(element: &Element) -> impl Iterator<Item = &Element> {
851    element
852        .child("subcomponents")
853        .into_iter()
854        .flat_map(Element::elements)
855}
856
857/// Unique ids: a `.ork` gives most components one, but not all of them, and `hpr-design` needs
858/// every id to be distinct or it refuses the whole design.
859///
860/// So every id handed out is remembered, whether it came from the file or was invented here. A
861/// file whose own ids repeat, or whose `<id>bodytube-4</id>` collides with the name invented for a
862/// component that has none, gets a number added and a warning rather than a design that will not
863/// open (issue #132).
864#[derive(Debug, Default)]
865pub(super) struct Ids {
866    used: usize,
867    taken: std::collections::BTreeSet<String>,
868    /// Every motor mount read, with the id its component was given, in file order: the walk
869    /// records them here because this is what it carries everywhere, and [`super::motors::read`]
870    /// needs the ids.
871    pub(super) mounts: Vec<(String, MountRead)>,
872    /// Every parachute and streamer read, with its component's id, in file order.
873    pub(super) devices: Vec<(String, DeviceRead)>,
874    /// Every stage that states when it separates, with the stage's id, in file order.
875    pub(super) separations: Vec<(String, SeparationRead)>,
876    /// The path of every stage and component read, for [`super::extensions::read`] to keep the
877    /// rest.
878    pub(super) read: std::collections::BTreeSet<String>,
879    /// The parallel stages read in the stage being read, each to follow it ([`stage`]).
880    pub(super) parallel: Vec<Stage>,
881    /// Whether parallel stages are read: on a rocket of one axial stage, where OpenRocket numbers
882    /// each right after it (ADR-171).
883    pub(super) parallel_stages_read: bool,
884}
885
886/// What the walk read besides the rocket, each with the id it gave its component.
887#[derive(Debug, Default)]
888pub(super) struct Walked {
889    /// The motor mounts.
890    pub mounts: Vec<(String, MountRead)>,
891    /// The parachutes and streamers.
892    pub devices: Vec<(String, DeviceRead)>,
893    /// The stages' separations.
894    pub separations: Vec<(String, SeparationRead)>,
895    /// The path of every stage and component read.
896    pub read: std::collections::BTreeSet<String>,
897}
898
899impl Ids {
900    pub(super) fn take(&mut self, values: &mut Values<'_>, kind: &str) -> String {
901        self.used += 1;
902        let wanted = values
903            .word(&["id"])
904            .filter(|id| !id.is_empty())
905            .unwrap_or_else(|| format!("{kind}-{}", self.used));
906        if self.taken.insert(wanted.clone()) {
907            return wanted;
908        }
909        let mut again = 2usize;
910        let id = loop {
911            let candidate = format!("{wanted}-{again}");
912            if self.taken.insert(candidate.clone()) {
913                break candidate;
914            }
915            again += 1;
916        };
917        values.warn_at(
918            WarningKind::Unusual,
919            format!(
920                "`{wanted}` is already the id of another component; this one was called `{id}`"
921            ),
922        );
923        // The design holds the new id, so the file's is kept as written.
924        values.forget(&["id"]);
925        id
926    }
927}
928
929/// `["bodytube", "finset", "bodytube"]` as `2 bodytube, 1 finset`.
930fn tally(names: &[String]) -> Option<String> {
931    if names.is_empty() {
932        return None;
933    }
934    let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
935    for name in names {
936        *counts.entry(name.as_str()).or_default() += 1;
937    }
938    Some(
939        counts
940            .into_iter()
941            .map(|(name, count)| format!("{count} `{name}`"))
942            .collect::<Vec<_>>()
943            .join(", "),
944    )
945}