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}