Skip to main content

hpr_io/ork/
mod.rs

1//! Reading OpenRocket `.ork` design files.
2//!
3//! **Guide:** [OpenRocket `.ork` design files][guide] says what this reads and what it leaves out.
4//!
5//! [guide]: https://nrdptel.github.io/hpr-sim/format/ork.html
6//!
7//! A `.ork` is a design: a tree of components, the materials they are made of, the motors flown in
8//! them, and the simulations OpenRocket last ran. This module is the first step of reading one:
9//! getting the design document out of whichever container it arrived in, and into a tree that
10//! keeps everything the file said. [`rocket`] turns that tree into [`hpr_design`] types: the spine,
11//! which is the stages and the body components stacked in them ([`component`]), and the parts on
12//! and inside each of those ([`attached`]).
13//!
14//! Nothing here reads a file: `hpr-io` does no I/O and builds for `wasm32-unknown-unknown`, so the
15//! caller supplies the bytes.
16//!
17//! ```
18//! # fn main() -> Result<(), hpr_io::ork::OrkError> {
19//! let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
20//! <openrocket version="1.10" creator="OpenRocket 24.12">
21//!   <rocket><name>Sounder</name></rocket>
22//! </openrocket>"#;
23//!
24//! let read = hpr_io::ork::read(xml)?;
25//! assert_eq!(read.value.container, hpr_io::ork::Container::Xml);
26//! assert_eq!(read.value.document.version.to_string(), "1.10");
27//! let rocket = read.value.document.root.child("rocket").expect("a rocket");
28//! assert_eq!(rocket.child("name").expect("a name").text(), "Sounder");
29//! assert!(read.warnings.is_empty());
30//! # Ok(())
31//! # }
32//! ```
33
34pub mod attached;
35pub mod component;
36pub mod container;
37pub mod document;
38mod error;
39pub mod export;
40pub mod extensions;
41pub mod motors;
42mod reads;
43pub mod recovery;
44pub mod simulations;
45pub mod staging;
46pub mod value;
47mod warning;
48
49pub use attached::ATTACHED_TAGS;
50pub use component::{OPENROCKET_DEFAULT_RADIUS_M, rocket};
51pub use container::{Attachment, Container, MAX_UNPACKED_BYTES, Unpacked};
52pub use document::{Document, Element, MAX_DEPTH, MAX_KNOWN_MINOR, Node, SchemaVersion};
53pub use error::OrkError;
54pub use extensions::{Extensions, Kept, KeptAttribute, OpenRocketExtension, element_at};
55pub use motors::{
56    CaseSize, Curve, Ignition, IgnitionEvent, LeftOut, MotorConfiguration, Motors, NoCurve,
57    NotFlown, OrkMotor, SuppliedCurves, UnreadMotor,
58};
59pub use recovery::{
60    DeployEvent, Deployment, DeviceKind, EventSetting, Recovery, RecoveryDevice, Separation,
61    SeparationEvent, StageSeparation, UnreadDevice,
62};
63pub use simulations::{
64    Atmosphere, LaunchConditions, OPENROCKET_CALCULATOR, OPENROCKET_SIMULATOR, StoredBranch,
65    StoredEvent, StoredReferenceExclusion, StoredResults, StoredSimulation, WindLevel,
66};
67pub use staging::{Staging, StagingTrigger};
68pub use value::{AXIAL_OFFSET, Dimension, INSTANCE_COUNT, Overrides, Values};
69pub use warning::{Imported, Warning, WarningKind};
70
71/// A `.ork` file, read: the container it came in, its design document, and everything else it
72/// carried.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct OrkFile {
75    /// Which of the three containers the bytes were packed in.
76    pub container: Container,
77    /// The archive entry the design came from, when it came from an archive.
78    pub design_entry: Option<String>,
79    /// The design document.
80    pub document: Document,
81    /// The archive's other entries, in archive order: thrust curves, preview images, lookup
82    /// tables. Held as stored, for the milestones that read them and for an export to put back.
83    pub attachments: Vec<Attachment>,
84}
85
86impl OrkFile {
87    /// The attachment called `name`, if the archive had one.
88    pub fn attachment(&self, name: &str) -> Option<&Attachment> {
89        self.attachments
90            .iter()
91            .find(|attachment| attachment.name == name)
92    }
93}
94
95/// A `.ork` design read whole: its rocket, carrying every motor configuration hpr can fly as
96/// written, and everything the file says about its motors.
97#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
98#[non_exhaustive]
99pub struct Design {
100    /// The rocket, as [`rocket`] reads it, with [`Rocket::configurations`] holding the
101    /// configurations in [`Design::motors`] that were not left out, each motor lit as the file
102    /// says. A configuration whose [`MotorConfiguration::staging`] is set must be flown with that
103    /// separation (`hpr::ork::separation`): without it the stack carries its booster to the ground
104    /// with the sustainer lit on it.
105    ///
106    /// [`Rocket::configurations`]: hpr_design::Rocket::configurations
107    pub rocket: hpr_design::Rocket,
108    /// Every motor configuration in the file, flown or not.
109    pub motors: Motors,
110    /// When each parachute and streamer opens and each stage separates.
111    pub recovery: Recovery,
112    /// The simulations OpenRocket last ran on the design, with their conditions and results.
113    pub simulations: Vec<StoredSimulation>,
114    /// What the file holds that hpr does not model, kept whole for an export to put back.
115    #[serde(default)]
116    pub extensions: Extensions,
117}
118
119impl Design {
120    /// A design from its parts, as the hpr design format holds them (`hpr_format::DesignFile`).
121    pub fn new(
122        rocket: hpr_design::Rocket,
123        motors: Motors,
124        recovery: Recovery,
125        simulations: Vec<StoredSimulation>,
126        extensions: Extensions,
127    ) -> Self {
128        Self {
129            rocket,
130            motors,
131            recovery,
132            simulations,
133            extensions,
134        }
135    }
136
137    /// Whether the rocket is reduced: the file describes parts of it (a pod, a parallel stage, a
138    /// part hpr cannot shape) that are kept in [`Design::extensions`] rather than read into it.
139    ///
140    /// The flag is on the `Design`, not on [`Design::rocket`]: check it before using the rocket on
141    /// its own. A part read as something simpler, such as a cluster of tubes read as one, does not
142    /// make a design reduced; its warning keeps every configuration of it from flying
143    /// ([ADR-055][adr-055], the rule for which configurations fly).
144    ///
145    /// [adr-055]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-055-m31c-split-and-the-motors-a-ork-flies-its-own-curve-first-and-only-what-lights-at-launch-2026-09-21
146    pub fn is_reduced(&self) -> bool {
147        !self.extensions.x_openrocket.parts.is_empty()
148    }
149
150    /// Returns why `simulation` cannot be used as a reference for this design.
151    ///
152    /// This combines the stored-result screen with hpr's design-reproduction screen. Use
153    /// [`StoredSimulation::reference_exclusion`] and [`Self::reproduction_exclusion`] separately
154    /// when those two questions need to be reported independently.
155    #[must_use]
156    pub fn reference_exclusion(
157        &self,
158        simulation: &StoredSimulation,
159    ) -> Option<StoredReferenceExclusion> {
160        simulation
161            .reference_exclusion()
162            .or_else(|| self.reproduction_exclusion(simulation))
163    }
164
165    /// Returns why hpr cannot reproduce the design named by a stored launch configuration.
166    ///
167    /// This is deliberately separate from [`StoredSimulation::reference_exclusion`]: a complete
168    /// OpenRocket result can be a useful stored reference even when hpr does not yet have its
169    /// motor curve or full airframe model. A caller that needs a result hpr can fly should apply
170    /// both classifiers.
171    #[must_use]
172    pub fn reproduction_exclusion(
173        &self,
174        simulation: &StoredSimulation,
175    ) -> Option<StoredReferenceExclusion> {
176        if self.is_reduced() {
177            return Some(StoredReferenceExclusion::ReducedDesign);
178        }
179        let Some(conditions) = simulation.conditions.as_ref() else {
180            return Some(StoredReferenceExclusion::MissingConfiguration);
181        };
182        let Some(configuration_id) = conditions.configuration.as_deref() else {
183            return Some(StoredReferenceExclusion::MissingConfiguration);
184        };
185        let Some(configuration) = self
186            .motors
187            .configurations
188            .iter()
189            .find(|configuration| configuration.id == configuration_id)
190        else {
191            return Some(StoredReferenceExclusion::UnknownConfiguration);
192        };
193        if configuration.left_out.is_some()
194            || self.rocket.configuration(configuration_id).is_none()
195            || self.rocket.assemble(configuration_id).is_err()
196        {
197            return Some(StoredReferenceExclusion::UnflyableConfiguration);
198        }
199        None
200    }
201}
202
203/// Reads the design in a `.ork` file: the rocket ([`rocket`]) and its motors ([`motors`]), with
204/// thrust curves from the archive's `thrustcurves/*.rse` entries or the bundled catalog.
205///
206/// ```
207/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
208/// let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
209/// <openrocket version="1.10" creator="OpenRocket 24.12">
210///   <rocket><name>Sounder</name>
211///     <motorconfiguration configid="c1" default="true"><name>F15</name></motorconfiguration>
212///     <subcomponents><stage><name>Sustainer</name><subcomponents>
213///       <nosecone><name>Nose</name><id>nose</id>
214///         <material type="bulk" density="1000.0">Plastic</material>
215///         <length>0.15</length><thickness>0.002</thickness><shape>ogive</shape>
216///         <aftradius>0.0165</aftradius></nosecone>
217///       <bodytube><name>Body</name><id>body</id>
218///         <material type="bulk" density="680.0">Cardboard</material>
219///         <length>0.6</length><thickness>0.001</thickness><radius>0.0165</radius>
220///         <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
221///           <overhang>0.005</overhang>
222///           <motor configid="c1"><type>single</type><manufacturer>Estes</manufacturer>
223///             <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
224///             <delay>4.0</delay></motor>
225///         </motormount></bodytube>
226///     </subcomponents></stage></subcomponents></rocket>
227/// </openrocket>"#;
228///
229/// let read = hpr_io::ork::read(xml)?;
230/// let design = hpr_io::ork::design(&read.value).value;
231///
232/// // The F15 has no curve in this file, so it comes from the bundled catalog...
233/// let motor = &design.motors.configurations[0].motors[0];
234/// assert!(matches!(motor.curve, hpr_io::ork::Curve::Catalog { .. }));
235/// assert_eq!(motor.delay, Some(hpr_motor::Delay::Seconds(4.0)));
236/// let impulse_ns = motor.curve.motor().expect("a curve").curve().total_impulse_ns();
237/// assert!((impulse_ns - 49.61).abs() < 0.01, "{impulse_ns}");
238///
239/// // ...and it ignites at launch, so the configuration is one the rocket flies.
240/// let assembly = design.rocket.assemble("c1")?;
241/// assert_eq!(assembly.motors[0].mount, "body");
242/// # Ok(())
243/// # }
244/// ```
245pub fn design(file: &OrkFile) -> Imported<Design> {
246    design_with(file, &SuppliedCurves::default())
247}
248
249/// Like [`design`], but a motor can also take its thrust curve from `supplied`.
250///
251/// Each motor looks in three places, in order: a curve embedded in the file, then a supplied
252/// curve for the motor's digest, then the bundled catalog. An embedded curve that does not read
253/// or build is passed over, with a warning, for the next place.
254///
255/// ```
256/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
257/// use hpr_io::ork::{CaseSize, Curve, SuppliedCurves};
258/// use hpr_motor::{SolidMotor, ThrustCurve};
259///
260/// let xml = br#"<?xml version='1.0' encoding='utf-8'?>
261/// <openrocket version="1.9" creator="example">
262///   <rocket><subcomponents><stage><name>Sustainer</name><subcomponents>
263///     <bodytube><id>body</id><length>0.4</length><thickness>0.001</thickness>
264///       <radius>0.0125</radius>
265///       <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
266///         <overhang>0.0</overhang>
267///         <motor configid="c1"><type>single</type><manufacturer>Example</manufacturer>
268///           <digest>0123abcd</digest><designation>E9</designation>
269///           <diameter>0.024</diameter><length>0.07</length><delay>4.0</delay></motor>
270///       </motormount></bodytube>
271///   </subcomponents></stage></subcomponents></rocket>
272/// </openrocket>"#;
273/// let read = hpr_io::ork::read(xml)?;
274///
275/// // A made-up 19 N·s motor: 10 N for about two seconds, from 10 g of propellant in a 30 g motor.
276/// let thrust = ThrustCurve::new(vec![0.0, 0.1, 1.9, 2.0], vec![0.0, 10.0, 10.0, 0.0])?;
277/// let (diameter_m, length_m, propellant_kg, loaded_kg) = (0.024, 0.07, 0.01, 0.03);
278/// let motor = SolidMotor::from_envelope(thrust, diameter_m, length_m, propellant_kg, loaded_kg)?;
279/// let mut supplied = SuppliedCurves::new("a motor database");
280/// supplied.insert("0123abcd", CaseSize { diameter_m, length_m }, motor)?;
281///
282/// let design = hpr_io::ork::design_with(&read.value, &supplied).value;
283/// let flown = &design.motors.configurations[0].motors[0];
284/// assert!(matches!(flown.curve, Curve::Supplied { .. }));
285/// assert!(design.rocket.assemble("c1").is_ok());
286/// # Ok(())
287/// # }
288/// ```
289pub fn design_with(file: &OrkFile, supplied: &SuppliedCurves) -> Imported<Design> {
290    // Every tag the readers ask for is recorded, so that the extension can keep the rest.
291    let ((mut design, warnings, read), reads) = reads::recording(|| {
292        let (mut design, mut warnings, read) = read_design(file, supplied);
293        // Stored simulations stand apart from the airframe, so their warnings come after the check
294        // in `read_design`; a document can hold them without a design, as Debrief's results-only
295        // file does.
296        design.simulations = simulations::read(&file.document, &mut warnings);
297        (design, warnings, read)
298    });
299    design.extensions = Extensions {
300        x_openrocket: extensions::read(&file.document, &read, &reads),
301    };
302    Imported {
303        value: design,
304        warnings,
305    }
306}
307
308/// Why the rocket in `file` was not read exactly as written, or `None` if it was. A rocket read
309/// otherwise flies in none of its configurations, whatever its motors ([ADR-055][adr-055], the
310/// rule for which configurations fly), so a program that puts a motor of its own into a
311/// configuration [`design`] left out, as `hpr sim --motor` does, asks this first: a
312/// configuration's [`LeftOut`] names only the first reason on [`NotFlown`]'s list, and a missing
313/// curve comes before the airframe.
314///
315/// [adr-055]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-055-m31c-split-and-the-motors-a-ork-flies-its-own-curve-first-and-only-what-lights-at-launch-2026-09-21
316#[must_use]
317pub fn airframe_not_as_written(file: &OrkFile) -> Option<String> {
318    let (rocket, _) = component::walk(&file.document);
319    incomplete(&rocket.warnings)
320}
321
322// Any warning the walk raised about the rocket or a motor mount means it was not read exactly as
323// written: a part left out, a value dropped or simplified, or something assumed. No configuration
324// of such a rocket is flown (ADR-055). A recovery device's or a stage separation's own warnings
325// are about when things happen, which is not flown (ADR-056), and their paths say so.
326fn incomplete(warnings: &[Warning]) -> Option<String> {
327    let skipped: Vec<&str> = warnings
328        .iter()
329        .filter(|w| {
330            ![recovery::DEPLOYMENT, recovery::DRAG, recovery::SEPARATION]
331                .iter()
332                .any(|segment| w.at.contains(segment))
333        })
334        .map(|w| w.message.as_str())
335        .collect();
336    match skipped.as_slice() {
337        [] => None,
338        [one] => Some((*one).to_owned()),
339        [first, rest @ ..] => Some(format!("{first}, and {} more", rest.len())),
340    }
341}
342
343/// The rocket, its motors and its recovery, the warnings reading them raised, and the paths of
344/// the stages and components read.
345fn read_design(
346    file: &OrkFile,
347    supplied: &SuppliedCurves,
348) -> (Design, Vec<Warning>, std::collections::BTreeSet<String>) {
349    let (rocket, walked) = component::walk(&file.document);
350    let Imported {
351        value: rocket,
352        mut warnings,
353    } = rocket;
354    let incomplete = incomplete(&warnings);
355    let mut design = Design {
356        rocket,
357        motors: Motors::default(),
358        recovery: Recovery::default(),
359        simulations: Vec::new(),
360        extensions: Extensions::default(),
361    };
362    if let Some(element) = file.document.root.child("rocket") {
363        // The separations first: a configuration flies only if its stages come apart as hpr
364        // can fly them.
365        design.recovery =
366            recovery::read(element, &design.rocket, walked.devices, walked.separations);
367        design.motors = motors::read(
368            element,
369            &mut design.rocket,
370            motors::Airframe {
371                incomplete: incomplete.as_deref(),
372                separations: &design.recovery.separations,
373            },
374            &walked.mounts,
375            &file.attachments,
376            supplied,
377            &mut warnings,
378        );
379    }
380    (design, warnings, walked.read)
381}
382
383/// Reads a `.ork` file from its bytes: sniffs the container, unpacks it, and parses the design.
384///
385/// # Errors
386///
387/// Returns [`OrkError`] when the bytes are not a `.ork` at all, when the container is damaged,
388/// when no entry in it can be the design document, or when that document is not well-formed XML
389/// rooted at `<openrocket>` with a readable schema version. Everything a reader can go on from is
390/// a [`Warning`] instead.
391pub fn read(bytes: &[u8]) -> Result<Imported<OrkFile>, OrkError> {
392    let unpacked = container::unpack(bytes)?;
393    let mut warnings = unpacked.warnings;
394    let Unpacked {
395        container,
396        design_entry,
397        design,
398        attachments,
399    } = unpacked.value;
400    let document = Document::parse(&design)?;
401    warnings.extend(document.warnings);
402    let document = document.value;
403    Ok(Imported {
404        value: OrkFile {
405            container,
406            design_entry,
407            document,
408            attachments,
409        },
410        warnings,
411    })
412}
413
414#[cfg(test)]
415mod tests;