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;