lib3mf_core/writer/
package_writer.rs

1use crate::error::{Lib3mfError, Result};
2use crate::model::Package;
3use crate::writer::opc_writer::{write_content_types, write_relationships};
4use std::io::{Seek, Write};
5use zip::ZipWriter;
6use zip::write::FileOptions;
7
8/// A writer that orchestrates the creation of a 3MF package (ZIP archive).
9pub struct PackageWriter<W: Write + Seek> {
10    zip: ZipWriter<W>,
11    options: FileOptions<'static, ()>,
12}
13
14impl<W: Write + Seek> PackageWriter<W> {
15    pub fn new(writer: W) -> Self {
16        let options = FileOptions::default()
17            .compression_method(zip::CompressionMethod::Deflated)
18            .unix_permissions(0o644);
19
20        Self {
21            zip: ZipWriter::new(writer),
22            options,
23        }
24    }
25
26    pub fn write(mut self, package: &Package) -> Result<()> {
27        // 1. Write Attachments (Textures, Thumbnails) from the main model
28        // (In a true multi-part, attachments might be shared or part-specific,
29        // but for now we aggregate them in the main model or handle them simply).
30        for (path, data) in &package.main_model.attachments {
31            let zip_path = path.trim_start_matches('/');
32            self.zip
33                .start_file(zip_path, self.options)
34                .map_err(|e| Lib3mfError::Io(e.into()))?;
35            self.zip.write_all(data).map_err(Lib3mfError::Io)?;
36        }
37
38        // 2. Prepare Relationships (Textures, Thumbnails) for 3D Model
39        // We do this BEFORE writing XML because objects need the Relationship ID for the 'thumbnail' attribute.
40        let mut model_rels = Vec::new();
41        let mut path_to_rel_id = std::collections::HashMap::new();
42
43        // A. Collect Textures from Attachments
44        for path in package.main_model.attachments.keys() {
45            if path.starts_with("3D/Textures/") || path.starts_with("/3D/Textures/") {
46                let target = if path.starts_with('/') {
47                    path.to_string()
48                } else {
49                    format!("/{}", path)
50                };
51
52                // Deduplicate? For now, we assume 1:1 path to rel or just create distinct rels per path
53                path_to_rel_id.entry(target.clone()).or_insert_with(|| {
54                    let id = format!("rel_tex_{}", model_rels.len());
55                    model_rels.push(crate::archive::opc::Relationship {
56                        id: id.clone(),
57                        rel_type: "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel/relationship/texture".to_string(),
58                        target: target.clone(),
59                        target_mode: "Internal".to_string(),
60                    });
61                    id
62                });
63            }
64        }
65
66        // B. Collect Object Thumbnails
67        for obj in package.main_model.resources.iter_objects() {
68            if let Some(thumb_path) = &obj.thumbnail {
69                let target = if thumb_path.starts_with('/') {
70                    thumb_path.clone()
71                } else {
72                    format!("/{}", thumb_path)
73                };
74
75                path_to_rel_id.entry(target.clone()).or_insert_with(|| {
76                    let id = format!("rel_thumb_{}", model_rels.len());
77                    model_rels.push(crate::archive::opc::Relationship {
78                        id: id.clone(),
79                        rel_type: "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel/relationship/thumbnail".to_string(),
80                        target: target.clone(),
81                        target_mode: "Internal".to_string(),
82                    });
83                    id
84                });
85            }
86        }
87
88        // 3. Write 3D Model parts
89        let main_path = "3D/3dmodel.model";
90        self.zip
91            .start_file(main_path, self.options)
92            .map_err(|e| Lib3mfError::Io(e.into()))?;
93
94        // Pass the relationship map to write_xml so it can write attributes
95        package
96            .main_model
97            .write_xml(&mut self.zip, Some(&path_to_rel_id))?;
98
99        for (path, model) in &package.parts {
100            self.zip
101                .start_file(path.trim_start_matches('/'), self.options)
102                .map_err(|e| Lib3mfError::Io(e.into()))?;
103            // TODO: Support relationships for other parts if they have their own thumbnails
104            model.write_xml(&mut self.zip, None)?;
105        }
106
107        // 4. Write Relationships (_rels/.rels and model relationships)
108        // Global Relationships
109        self.zip
110            .start_file("_rels/.rels", self.options)
111            .map_err(|e| Lib3mfError::Io(e.into()))?;
112
113        let package_thumb = package
114            .main_model
115            .attachments
116            .keys()
117            .find(|k| k == &"Metadata/thumbnail.png" || k == &"/Metadata/thumbnail.png")
118            .map(|k| {
119                if k.starts_with('/') {
120                    k.clone()
121                } else {
122                    format!("/{}", k)
123                }
124            });
125
126        write_relationships(
127            &mut self.zip,
128            &format!("/{}", main_path),
129            package_thumb.as_deref(),
130        )?;
131
132        // Model Relationships (e.g. 3D/_rels/3dmodel.model.rels)
133        // Merge existing relationships with new texture/thumbnail relationships
134        let model_rels_path = "3D/_rels/3dmodel.model.rels";
135
136        // Start with existing relationships if available
137        let mut all_model_rels = package
138            .main_model
139            .existing_relationships
140            .get(model_rels_path)
141            .cloned()
142            .unwrap_or_default();
143
144        // Add new texture/thumbnail relationships
145        // Use a HashSet to track existing IDs to avoid duplicates
146        let existing_ids: std::collections::HashSet<String> =
147            all_model_rels.iter().map(|r| r.id.clone()).collect();
148
149        for rel in model_rels {
150            if !existing_ids.contains(&rel.id) {
151                all_model_rels.push(rel);
152            }
153        }
154
155        // Write merged relationships if any exist
156        if !all_model_rels.is_empty() {
157            self.zip
158                .start_file(model_rels_path, self.options)
159                .map_err(|e| Lib3mfError::Io(e.into()))?;
160
161            let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
162            xml.push_str("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n");
163            for rel in all_model_rels {
164                xml.push_str(&format!(
165                    "  <Relationship Target=\"{}\" Id=\"{}\" Type=\"{}\" />\n",
166                    rel.target, rel.id, rel.rel_type
167                ));
168            }
169            xml.push_str("</Relationships>");
170
171            self.zip
172                .write_all(xml.as_bytes())
173                .map_err(Lib3mfError::Io)?;
174        }
175
176        // 4. Write Content Types
177        self.zip
178            .start_file("[Content_Types].xml", self.options)
179            .map_err(|e| Lib3mfError::Io(e.into()))?;
180        write_content_types(&mut self.zip)?;
181
182        self.zip.finish().map_err(|e| Lib3mfError::Io(e.into()))?;
183        Ok(())
184    }
185}