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;