Skip to main content

hpr_io/ork/export/
mod.rs

1//! Writing a design back out as an OpenRocket `.ork` file.
2//!
3//! **Guide:** [OpenRocket `.ork` design files][guide] says what is written and what is not.
4//!
5//! [guide]: https://nrdptel.github.io/hpr-sim/format/ork.html
6//!
7//! The file is written from the [`Design`] alone: its rocket, its motors, when its recovery
8//! opens and its stages separate, and the simulations OpenRocket last ran, each written as the
9//! tags [`super::design`] reads them from. Everything the design keeps but hpr does not model
10//! (its [`super::Extensions`], such as a pod set, a part's color or a simulation's extension) is
11//! put back where it was. So a `.ork` read and written again reads back as the same design, which
12//! is how `cargo xtask ork` checks this on every file in the reference library
13//! ([ADR-109][adr-109]).
14//!
15//! What is written is schema 1.10, the version OpenRocket 24.12 writes, in a zip archive with
16//! the design as `rocket.ork`, as OpenRocket packs one.
17//!
18//! [adr-109]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-109-m32-split-and-a-ork-written-from-the-design-2026-09-29
19
20mod kept;
21mod motors;
22mod recovery;
23mod rocket;
24mod simulations;
25mod xml;
26
27use std::io::{Cursor, Write as _};
28
29use super::container::Attachment;
30use super::document::{Document, SchemaVersion};
31use super::error::OrkError;
32use super::warning::{Imported, Warning};
33use super::{Design, MAX_KNOWN_MINOR};
34use xml::Build as _;
35
36/// The schema version written: OpenRocket 24.12's.
37pub const SCHEMA: SchemaVersion = SchemaVersion {
38    major: 1,
39    minor: 10,
40};
41
42/// The `creator` written on the document: the program's name, version and designation in the
43/// FusionSpace product system, as [`hpr_core::tool::stamp`] writes them
44/// (`hpr-sim 0.1.0 · FS · SW · TOOL 005`). OpenRocket keeps the attribute as text and does not
45/// parse it.
46#[must_use]
47pub fn creator() -> String {
48    hpr_core::tool::stamp()
49}
50
51/// The archive entry the design is written to, where OpenRocket looks for it first.
52pub const DESIGN_ENTRY: &str = "rocket.ork";
53
54// Schema 1.10 is one this crate reads as known; a newer written version would need its reader.
55const _: () = assert!(SCHEMA.major == 1 && SCHEMA.minor <= MAX_KNOWN_MINOR);
56
57/// The design document for `design`, with a warning for anything kept from the file it came from
58/// that has no place in it any more.
59///
60/// ```
61/// # fn main() -> Result<(), hpr_io::ork::OrkError> {
62/// let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
63/// <openrocket version="1.10" creator="OpenRocket 24.12">
64///   <rocket><name>Sounder</name><designer>A. Flyer</designer></rocket>
65/// </openrocket>"#;
66/// let read = hpr_io::ork::read(xml)?;
67/// let design = hpr_io::ork::design(&read.value).value;
68///
69/// let written = hpr_io::ork::export::document(&design);
70/// assert!(written.warnings.is_empty());
71/// let text = written.value.to_xml();
72/// // The name is the rocket's; the designer, which hpr does not read, was kept and put back.
73/// assert!(text.contains("<name>Sounder</name>"), "{text}");
74/// assert!(text.contains("<designer>A. Flyer</designer>"), "{text}");
75/// # Ok(())
76/// # }
77/// ```
78pub fn document(design: &Design) -> Imported<Document> {
79    let mut warnings: Vec<Warning> = Vec::new();
80    let mut root = xml::element("openrocket");
81    root.with_attribute("version", SCHEMA.to_string())
82        .with_attribute("creator", creator());
83    root.push(rocket::rocket(design, &mut warnings));
84    if let Some(simulations) = simulations::simulations(design, &mut warnings) {
85        root.push(simulations);
86    }
87    let mut document = Document {
88        version: SCHEMA,
89        creator: Some(creator()),
90        root,
91    };
92    warnings.extend(kept::splice(&mut document, &design.extensions.x_openrocket));
93    Imported {
94        value: document,
95        warnings,
96    }
97}
98
99/// A `.ork` file for `design`: a zip archive holding its document as `rocket.ork`, and then
100/// `attachments`, such as the thrust curves and preview image of the file it was read from
101/// ([`super::OrkFile::attachments`]). An attachment called `rocket.ork` is left out, with a
102/// warning: the design is that entry.
103///
104/// # Errors
105///
106/// [`OrkError::Zip`] if the archive cannot be written, which in memory means an attachment's name
107/// is one no zip entry can have.
108pub fn write(design: &Design, attachments: &[Attachment]) -> Result<Imported<Vec<u8>>, OrkError> {
109    let Imported {
110        value: document,
111        mut warnings,
112    } = document(design);
113    let zip = |error: &dyn std::fmt::Display| OrkError::Zip {
114        reason: error.to_string(),
115    };
116    // Every entry is stamped 1980-01-01, zip's zero date, so the bytes written never depend on the
117    // clock. `SimpleFileOptions::default()` reads the clock when a crate further down the build
118    // enables zip's `time` feature, and on `wasm32-unknown-unknown` that call panics, so the options
119    // start from the constant `DEFAULT` instead and name the date as well.
120    let options = zip::write::SimpleFileOptions::DEFAULT
121        .compression_method(zip::CompressionMethod::Deflated)
122        .last_modified_time(zip::DateTime::DEFAULT);
123    let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
124    archive
125        .start_file(DESIGN_ENTRY, options)
126        .map_err(|e| zip(&e))?;
127    archive
128        .write_all(document.to_xml().as_bytes())
129        .map_err(|e| zip(&e))?;
130    for attachment in attachments {
131        if attachment.name == DESIGN_ENTRY {
132            warnings.push(Warning::new(
133                DESIGN_ENTRY,
134                super::WarningKind::Skipped,
135                "an attachment has the design's own name, `rocket.ork`; it was left out",
136            ));
137            continue;
138        }
139        archive
140            .start_file(attachment.name.as_str(), options)
141            .map_err(|e| zip(&e))?;
142        archive.write_all(&attachment.bytes).map_err(|e| zip(&e))?;
143    }
144    let bytes = archive.finish().map_err(|e| zip(&e))?.into_inner();
145    Ok(Imported {
146        value: bytes,
147        warnings,
148    })
149}
150
151#[cfg(test)]
152mod tests;