lib3mf_core/writer/
model_writer.rs

1use crate::error::Result;
2use crate::model::{BooleanOperationType, Geometry, Model, Unit};
3use crate::writer::displacement_writer::{write_displacement_2d, write_displacement_mesh};
4use crate::writer::mesh_writer::write_mesh;
5use crate::writer::xml_writer::XmlWriter;
6use std::io::Write;
7
8use std::collections::HashMap;
9
10/// Formats a transformation matrix into the 3MF format (12 space-separated values in column-major order)
11fn format_transform_matrix(mat: &glam::Mat4) -> String {
12    format!(
13        "{} {} {} {} {} {} {} {} {} {} {} {}",
14        mat.x_axis.x,
15        mat.x_axis.y,
16        mat.x_axis.z,
17        mat.y_axis.x,
18        mat.y_axis.y,
19        mat.y_axis.z,
20        mat.z_axis.x,
21        mat.z_axis.y,
22        mat.z_axis.z,
23        mat.w_axis.x,
24        mat.w_axis.y,
25        mat.w_axis.z
26    )
27}
28
29impl Model {
30    pub fn write_xml<W: Write>(
31        &self,
32        writer: W,
33        thumbnail_relationships: Option<&HashMap<String, String>>,
34    ) -> Result<()> {
35        let mut xml = XmlWriter::new(writer);
36        xml.write_declaration()?;
37
38        let root = xml
39            .start_element("model")
40            .attr("unit", self.unit_str())
41            .attr("xml:lang", self.language.as_deref().unwrap_or("en-US"))
42            .attr(
43                "xmlns",
44                "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
45            )
46            .attr(
47                "xmlns:m",
48                "http://schemas.microsoft.com/3dmanufacturing/material/2015/02",
49            )
50            .attr(
51                "xmlns:p",
52                "http://schemas.microsoft.com/3dmanufacturing/production/2015/06",
53            )
54            .attr(
55                "xmlns:b",
56                "http://schemas.3mf.io/3dmanufacturing/booleanoperations/2023/07",
57            )
58            .attr(
59                "xmlns:d",
60                "http://schemas.microsoft.com/3dmanufacturing/displacement/2024/01",
61            );
62
63        // Add typical namespaces if needed (e.g. production, slice) - strictly core for now
64        root.write_start()?;
65
66        // Metadata
67        for (key, value) in &self.metadata {
68            xml.start_element("metadata")
69                .attr("name", key)
70                .write_start()?;
71            xml.write_text(value)?;
72            xml.end_element("metadata")?;
73        }
74
75        // Resources
76        xml.start_element("resources").write_start()?;
77
78        // Write material resources first (colorgroups, basematerials, textures, etc.)
79        for color_group in self.resources.iter_color_groups() {
80            xml.start_element("colorgroup")
81                .attr("id", &color_group.id.0.to_string())
82                .write_start()?;
83            for color in &color_group.colors {
84                xml.start_element("color")
85                    .attr("color", &color.to_hex())
86                    .write_empty()?;
87            }
88            xml.end_element("colorgroup")?;
89        }
90
91        for base_materials in self.resources.iter_base_materials() {
92            xml.start_element("m:basematerials")
93                .attr("id", &base_materials.id.0.to_string())
94                .write_start()?;
95            for material in &base_materials.materials {
96                xml.start_element("m:base")
97                    .attr("name", &material.name)
98                    .attr("displaycolor", &material.display_color.to_hex())
99                    .write_empty()?;
100            }
101            xml.end_element("m:basematerials")?;
102        }
103
104        for texture_group in self.resources.iter_textures() {
105            xml.start_element("m:texture2dgroup")
106                .attr("id", &texture_group.id.0.to_string())
107                .attr("texid", &texture_group.texture_id.0.to_string())
108                .write_start()?;
109            for coord in &texture_group.coords {
110                xml.start_element("m:tex2coord")
111                    .attr("u", &coord.u.to_string())
112                    .attr("v", &coord.v.to_string())
113                    .write_empty()?;
114            }
115            xml.end_element("m:texture2dgroup")?;
116        }
117
118        for composite in self.resources.iter_composite_materials() {
119            xml.start_element("m:compositematerials")
120                .attr("id", &composite.id.0.to_string())
121                .attr("matid", &composite.base_material_id.0.to_string())
122                .write_start()?;
123            for comp in &composite.composites {
124                xml.start_element("m:composite")
125                    .attr(
126                        "values",
127                        &comp
128                            .values
129                            .iter()
130                            .map(|v| v.to_string())
131                            .collect::<Vec<_>>()
132                            .join(" "),
133                    )
134                    .write_empty()?;
135            }
136            xml.end_element("m:compositematerials")?;
137        }
138
139        for multi_props in self.resources.iter_multi_properties() {
140            xml.start_element("m:multiproperties")
141                .attr("id", &multi_props.id.0.to_string())
142                .attr(
143                    "pids",
144                    &multi_props
145                        .pids
146                        .iter()
147                        .map(|id| id.0.to_string())
148                        .collect::<Vec<_>>()
149                        .join(" "),
150                )
151                .write_start()?;
152            for multi in &multi_props.multis {
153                xml.start_element("m:multi")
154                    .attr(
155                        "pindices",
156                        &multi
157                            .pindices
158                            .iter()
159                            .map(|idx: &u32| idx.to_string())
160                            .collect::<Vec<_>>()
161                            .join(" "),
162                    )
163                    .write_empty()?;
164            }
165            xml.end_element("m:multiproperties")?;
166        }
167
168        // Write displacement texture resources
169        for displacement_2d in self.resources.iter_displacement_2d() {
170            write_displacement_2d(&mut xml, displacement_2d)?;
171        }
172
173        // Write objects
174        for obj in self.resources.iter_objects() {
175            match &obj.geometry {
176                Geometry::BooleanShape(bs) => {
177                    // BooleanShape is written as a booleanshape resource (not an object)
178                    let mut bool_elem = xml
179                        .start_element("b:booleanshape")
180                        .attr("id", &obj.id.0.to_string())
181                        .attr("objectid", &bs.base_object_id.0.to_string());
182
183                    if bs.base_transform != glam::Mat4::IDENTITY {
184                        bool_elem = bool_elem
185                            .attr("transform", &format_transform_matrix(&bs.base_transform));
186                    }
187                    if let Some(path) = &bs.base_path {
188                        bool_elem = bool_elem.attr("p:path", path);
189                    }
190
191                    bool_elem.write_start()?;
192
193                    // Write nested boolean operations
194                    for op in &bs.operations {
195                        let op_type_str = match op.operation_type {
196                            BooleanOperationType::Union => "union",
197                            BooleanOperationType::Difference => "difference",
198                            BooleanOperationType::Intersection => "intersection",
199                        };
200
201                        let mut op_elem = xml
202                            .start_element("b:boolean")
203                            .attr("objectid", &op.object_id.0.to_string())
204                            .attr("operation", op_type_str);
205
206                        if op.transform != glam::Mat4::IDENTITY {
207                            op_elem =
208                                op_elem.attr("transform", &format_transform_matrix(&op.transform));
209                        }
210                        if let Some(path) = &op.path {
211                            op_elem = op_elem.attr("p:path", path);
212                        }
213
214                        op_elem.write_empty()?;
215                    }
216
217                    xml.end_element("b:booleanshape")?;
218                }
219                _ => {
220                    // Write as a regular object element
221                    let mut obj_elem = xml
222                        .start_element("object")
223                        .attr("id", &obj.id.0.to_string())
224                        .attr("type", &obj.object_type.to_string());
225
226                    if let Some(pid) = obj.part_number.as_ref() {
227                        obj_elem = obj_elem.attr("partnumber", pid);
228                    }
229                    if let Some(uuid) = obj.uuid.as_ref() {
230                        obj_elem = obj_elem.attr("p:UUID", &uuid.to_string());
231                    }
232                    if let Some(name) = obj.name.as_ref() {
233                        obj_elem = obj_elem.attr("name", name);
234                    }
235                    if let Some(thumb_path) = obj.thumbnail.as_ref()
236                        && let Some(rels) = thumbnail_relationships
237                    {
238                        // Try to match exact path or normalized path
239                        let lookup_key = if thumb_path.starts_with('/') {
240                            thumb_path.clone()
241                        } else {
242                            format!("/{}", thumb_path)
243                        };
244
245                        if let Some(rel_id) = rels.get(&lookup_key) {
246                            obj_elem = obj_elem.attr("thumbnail", rel_id);
247                        }
248                    }
249
250                    obj_elem.write_start()?;
251
252                    match &obj.geometry {
253                        Geometry::Mesh(mesh) => write_mesh(&mut xml, mesh)?,
254                        Geometry::Components(comps) => {
255                            xml.start_element("components").write_start()?;
256                            for c in &comps.components {
257                                let mut comp = xml
258                                    .start_element("component")
259                                    .attr("objectid", &c.object_id.0.to_string());
260
261                                if let Some(path) = c.path.as_ref() {
262                                    comp = comp.attr("p:path", path);
263                                }
264                                if let Some(uuid) = c.uuid.as_ref() {
265                                    comp = comp.attr("p:UUID", &uuid.to_string());
266                                }
267
268                                if c.transform != glam::Mat4::IDENTITY {
269                                    comp = comp
270                                        .attr("transform", &format_transform_matrix(&c.transform));
271                                }
272                                comp.write_empty()?;
273                            }
274                            xml.end_element("components")?;
275                        }
276                        Geometry::SliceStack(_id) => {
277                            // Logic for SliceStack writing requires setting attribute on object element
278                            // But object element is already started.
279                            // This writer structure makes it hard to add attributes conditionally based on geometry type
280                            // unless we peek geometry before starting object element.
281                            // For now, I will assume writing slice models via this writer is not fully supported
282                            // or requires refactoring.
283                            // I will leave it empty as SliceStack objects have no body content (mesh/components).
284                            // BUT they need `slicestackid` on the object tag.
285                            // Refactoring needed to support Slice extension writing.
286                            // Phase 11 goal is parsing/validation. I will skip writing implementation logic but fix valid Rust match.
287                        }
288                        Geometry::VolumetricStack(_id) => {
289                            // Similar to SliceStack, requires attribute on object tag.
290                        }
291                        Geometry::BooleanShape(_) => {
292                            // This case is handled in the outer match, will never reach here
293                            unreachable!("BooleanShape handled in outer match")
294                        }
295                        Geometry::DisplacementMesh(mesh) => {
296                            write_displacement_mesh(&mut xml, mesh)?;
297                        }
298                    }
299
300                    xml.end_element("object")?;
301                }
302            }
303        }
304        xml.end_element("resources")?;
305
306        // Build
307        xml.start_element("build").write_start()?;
308        for item in &self.build.items {
309            let mut build_item = xml
310                .start_element("item")
311                .attr("objectid", &item.object_id.0.to_string());
312
313            if item.transform != glam::Mat4::IDENTITY {
314                build_item =
315                    build_item.attr("transform", &format_transform_matrix(&item.transform));
316            }
317            // partnumber support if needed
318            build_item.write_empty()?;
319        }
320        xml.end_element("build")?;
321
322        xml.end_element("model")?;
323        Ok(())
324    }
325
326    fn unit_str(&self) -> &'static str {
327        match self.unit {
328            Unit::Micron => "micron",
329            Unit::Millimeter => "millimeter",
330            Unit::Centimeter => "centimeter",
331            Unit::Inch => "inch",
332            Unit::Foot => "foot",
333            Unit::Meter => "meter",
334        }
335    }
336}