Skip to main content

brep_kernel/io/three_mf/
write.rs

1//! 3MF (3D Manufacturing Format) EXPORT of display meshes — the write half of
2//! this module, for slicers and print services. Like the GLB and STL lanes it
3//! writes tessellated triangles, not the exact B-rep: no surface, topology or
4//! feature tree leaves here.
5//!
6//! # The package
7//!
8//! A 3MF is an OPC ZIP container with three parts: `[Content_Types].xml`,
9//! `_rels/.rels` (the relationship that names the start part) and the model
10//! part `3D/3dmodel.model`. Every entry is written STORED (method 0) with a
11//! fixed timestamp, so the archive is a plain header + bytes + central
12//! directory and the same input produces the same file on every run. Stored
13//! rather than deflated on purpose: the kernel's deflate side is a decoder
14//! only (`inflate.rs`), the dependency review in [`super`] rules out pulling a
15//! compressor in for one lane, and a slicer reads either. The cost is size —
16//! roughly the model part's XML, three to four times a binary STL of the same
17//! triangles.
18//!
19//! # The model part
20//!
21//! The 3MF Core Specification 1.x only — the same namespace [`super::read_3mf`]
22//! reads, and no `requiredextensions`:
23//!
24//! * `<model unit="millimeter">`: every export lane writes millimetres.
25//! * ONE `<basematerials id="1">` holding every distinct colour in the file as
26//!   a `<base name="color_rrggbb" displaycolor="#RRGGBB"/>`, deduplicated
27//!   across bodies, in first-seen order. `<basematerials>` is a CORE element
28//!   (the materials-and-properties extension adds colour groups, textures and
29//!   composites on top of it), which is why a reader that knows only the core
30//!   specification — a slicer, Windows' viewer, lib3mf, this module's own
31//!   reader — sees the colours.
32//! * One `<object type="model">` per solid, named by its scene name, carrying
33//!   the body colour as `pid`/`pindex`; a triangle whose colour differs from
34//!   the body's (a face the document painted) carries its own `pid`/`p1`. Only
35//!   `p1` is ever written: a base material is one colour per triangle, and the
36//!   specification requires `p2`/`p3` to match it when present.
37//! * One `<build>` `<item>` per object, at the identity — the display mesh is
38//!   already in world coordinates.
39//!
40//! A solid with no colour at all is written with no `pid`, which the format
41//! defines as "no material": the slicer shows it in its own filament colour
42//! instead of a grey this writer invented. (The GLB lane paints such a body in
43//! the viewport's colour because glTF has no such state; 3MF does.)
44//!
45//! # Slicer painting, beside the base materials
46//!
47//! The slicers people print with do not read base materials: PrusaSlicer has
48//! ignored them since 2020 (its issue #4503) and shows colour only as
49//! multi-material PAINTING — per-triangle extruder assignments it stores on
50//! the `<triangle>` as `slic3rpe:mmu_segmentation` (namespace
51//! `http://schemas.slic3r.org/3mf/2017/06`), or, since 2.7.4, Bambu Studio's
52//! (and its forks') `paint_color`, which is the same bitstream under the name
53//! that fork chose. So every coloured triangle also carries both attributes:
54//! each distinct colour, in first-seen order, becomes an extruder slot, up to
55//! [`MAX_PAINT_SLOTS`] (the format stores a state in four bits and
56//! PrusaSlicer's `TriangleStateType` stops at `Extruder15`); a colour past
57//! that is still a base material but not painted. An uncoloured triangle has
58//! no painting and takes the object's default extruder — which is extruder 1.
59//! So when the file has any uncoloured triangle the colours start at slot 2:
60//! otherwise the one painted face of an unpainted body would land on the same
61//! extruder as the body and look exactly as it did before. A file whose every
62//! triangle is coloured starts at slot 1.
63//!
64//! The string's layout was taken from how PrusaSlicer 2.9.2 reads it
65//! (`FacetsAnnotation::set_triangle_from_string`, `TriangleSelector::deserialize`
66//! — format references only; the encoder below is this module's own): hex
67//! nibbles, the LAST
68//! character holding the FIRST nibble, each nibble's bits least significant
69//! first. An unsplit triangle is `split sides = 0` in two bits, then its
70//! state: a state below 3 in two bits (so slot 1 is `4`, slot 2 is `8`), a
71//! state from 3 up as the two bits `11` followed by a nibble of `state - 3`
72//! (slot 3 is `0C`, slot 4 `1C`, slot 15 `CC`). Nothing here is split, so no
73//! string is longer than two characters. The slicer shows the painting in ITS
74//! filament colours, slot by slot, which is what painting means to it; the
75//! colour itself stays in the base material for readers that honour one.
76//!
77//! # Vertices are welded
78//!
79//! The display mesh duplicates every vertex per face so each can carry its
80//! face's normal. 3MF has no normals, and the core specification wants a
81//! `model` object's mesh to be a closed, consistently wound manifold with each
82//! edge shared by exactly two triangles — which a per-face vertex soup is not.
83//! So positions are welded on bit-identical coordinates before writing: the
84//! watertight tessellation places the same point at a shared boundary vertex,
85//! so an exact match recovers the shared edges without inventing a tolerance.
86//! A triangle that collapses onto a repeated index is dropped and counted
87//! ([`ThreeMfWriteReport::degenerate_triangles`]) rather than written, since
88//! the core specification — and this module's reader — refuse one.
89//!
90//! Coordinates are written from the `f32` the display mesh holds, through
91//! Rust's shortest round-tripping `Display`, so `10` is `10` and not
92//! `10.000000000000000`, and the file re-reads to the triangles an STL export
93//! of the same scene carries.
94
95use std::collections::{BTreeMap, HashMap};
96
97use super::zip::crc32;
98use super::CORE_NAMESPACE;
99use crate::Mesh;
100
101/// One solid to write: its display mesh plus the colours the document gave it.
102#[derive(Clone, Debug, Default)]
103pub struct ThreeMfSolid {
104    /// The scene name — becomes the `<object name>`.
105    pub name: String,
106    /// Positions / triangle indices, and the per-TRIANGLE face id the
107    /// `face_colors` keys match. Normals are not written. An empty `face_ids`
108    /// means "one group".
109    pub mesh: Mesh,
110    /// The body colour, 8-bit sRGB. `None` writes the object with no material.
111    pub color: Option<[u8; 3]>,
112    /// Per-face colour overrides, keyed by the face id in `mesh.face_ids`.
113    /// A face listed here wins over `color` for its own triangles.
114    pub face_colors: BTreeMap<u32, [u8; 3]>,
115}
116
117/// What one written package contains — the counts a caller reports or asserts on.
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119pub struct ThreeMfWriteReport {
120    /// `<object>`s written (solids that kept at least one triangle).
121    pub objects: usize,
122    /// Triangles written, across every object.
123    pub triangles: usize,
124    /// Vertices written after welding, across every object.
125    pub vertices: usize,
126    /// Triangles dropped because welding collapsed two of their corners.
127    pub degenerate_triangles: usize,
128    /// Distinct colours — `<base>` entries of the one `<basematerials>`.
129    pub materials: usize,
130    /// Triangles carrying their own `pid`/`p1`, i.e. coloured unlike their body.
131    pub colored_triangles: usize,
132    /// Triangles carrying slicer painting (`slic3rpe:mmu_segmentation` and
133    /// `paint_color`): every coloured triangle whose colour got a slot.
134    pub painted_triangles: usize,
135    /// The first extruder slot the colours take: 1 when every written
136    /// triangle is coloured, 2 when any is not (slot 1 is then the default
137    /// extruder the uncoloured triangles print with).
138    pub first_paint_slot: usize,
139    /// Distinct colours that got a slot: `min(materials, MAX_PAINT_SLOTS + 1 - first_paint_slot)`.
140    pub paint_slots: usize,
141    /// Distinct colours past the slot limit, written as base materials only.
142    pub unpainted_colors: usize,
143    /// Total package length in bytes.
144    pub bytes: usize,
145}
146
147/// PrusaSlicer's painting namespace, declared on `<model>` so the prefixed
148/// attribute is well-formed XML.
149const SLIC3RPE_NAMESPACE: &str = "http://schemas.slic3r.org/3mf/2017/06";
150/// The most extruder slots the painting can name: PrusaSlicer's
151/// `TriangleStateType` runs `Extruder1 ..= Extruder15`.
152pub const MAX_PAINT_SLOTS: usize = 15;
153
154/// The painting string for an UNSPLIT triangle in extruder `slot` (1-based,
155/// at most [`MAX_PAINT_SLOTS`]) — see the module doc for the bitstream.
156fn painting_code(slot: usize) -> String {
157    debug_assert!((1..=MAX_PAINT_SLOTS).contains(&slot));
158    if slot < 3 {
159        // Nibble bits (least significant first): split 0, 0, then the two
160        // state bits — the state shifted up by two.
161        format!("{:X}", slot << 2)
162    } else {
163        // First nibble `1100` (= C: no split, state prefix 11), then a nibble
164        // of state - 3; the first nibble is the LAST character.
165        format!("{:X}C", slot - 3)
166    }
167}
168
169/// The package part names, as the OPC conventions and the reader expect them.
170const CONTENT_TYPES_PART: &str = "[Content_Types].xml";
171const RELATIONSHIPS_PART: &str = "_rels/.rels";
172const MODEL_PART: &str = "3D/3dmodel.model";
173/// The id of the one `<basematerials>`; objects number from the next id
174/// whether or not it is written, so a plain export and a coloured one agree.
175const BASE_MATERIALS_ID: u32 = 1;
176const FIRST_OBJECT_ID: u32 = 2;
177
178const CONTENT_TYPES_XML: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
179<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n  \
180<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n  \
181<Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n\
182</Types>\n";
183
184const RELATIONSHIPS_XML: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
185<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n  \
186<Relationship Id=\"rel0\" Type=\"http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel\" Target=\"/3D/3dmodel.model\"/>\n\
187</Relationships>\n";
188
189/// An XML attribute value or text node, with the five escapes the grammar
190/// needs. Scene names carry `:` today and nothing stops `&` or `"` tomorrow.
191fn xml_escape(value: &str) -> String {
192    let mut out = String::with_capacity(value.len());
193    for ch in value.chars() {
194        match ch {
195            '&' => out.push_str("&amp;"),
196            '<' => out.push_str("&lt;"),
197            '>' => out.push_str("&gt;"),
198            '"' => out.push_str("&quot;"),
199            '\'' => out.push_str("&apos;"),
200            // A control character is not XML 1.0 text at all; drop it rather
201            // than write a part the reader rejects as not well-formed.
202            c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
203            c => out.push(c),
204        }
205    }
206    out
207}
208
209/// `#RRGGBB`, upper case — the spelling the document's `color` attribute
210/// persists, so a round trip through the reader is byte-exact.
211fn display_color(rgb: [u8; 3]) -> String {
212    format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
213}
214
215/// A coordinate as the file carries it: the display mesh's `f32`, printed in
216/// its shortest round-tripping form.
217fn coordinate(value: f64) -> Result<String, String> {
218    let narrow = value as f32;
219    if !narrow.is_finite() {
220        return Err("write_3mf: non-finite vertex coordinate".into());
221    }
222    // `-0` would be a different key from `0` below and a pointless sign here.
223    let narrow = if narrow == 0.0 { 0.0 } else { narrow };
224    Ok(format!("{narrow}"))
225}
226
227/// Colour of one triangle: its face's override if it has one, else the body's.
228fn triangle_color(solid: &ThreeMfSolid, triangle: usize) -> Option<[u8; 3]> {
229    solid
230        .mesh
231        .face_ids
232        .get(triangle)
233        .and_then(|face| solid.face_colors.get(face))
234        .copied()
235        .or(solid.color)
236}
237
238/// The colour palette: distinct colours in first-seen order, and each one's
239/// index — the `pindex` / `p1` a triangle names.
240#[derive(Default)]
241struct Palette {
242    colors: Vec<[u8; 3]>,
243    index: HashMap<[u8; 3], u32>,
244}
245
246impl Palette {
247    fn intern(&mut self, rgb: [u8; 3]) -> u32 {
248        if let Some(index) = self.index.get(&rgb) {
249            return *index;
250        }
251        let index = self.colors.len() as u32;
252        self.colors.push(rgb);
253        self.index.insert(rgb, index);
254        index
255    }
256}
257
258/// One object's welded mesh, ready to print.
259struct WeldedObject {
260    id: u32,
261    name: String,
262    /// The printed coordinate strings, three per vertex.
263    vertices: Vec<[String; 3]>,
264    /// Triangle corners into `vertices`, plus the triangle's palette index.
265    triangles: Vec<([u32; 3], Option<u32>)>,
266    /// The body's palette index.
267    body: Option<u32>,
268}
269
270/// Weld one solid's vertices on their printed coordinates and resolve its
271/// colours into the palette. `None` when no triangle survives.
272fn weld(
273    solid: &ThreeMfSolid,
274    id: u32,
275    palette: &mut Palette,
276    report: &mut ThreeMfWriteReport,
277) -> Result<Option<WeldedObject>, String> {
278    solid.mesh.validate_geometry()?;
279    if solid.mesh.indices.is_empty() {
280        return Ok(None);
281    }
282    let body = solid.color.map(|rgb| palette.intern(rgb));
283    let source_vertices = solid.mesh.positions.len() / 3;
284    // Weld on the printed strings: two coordinates that print alike are the
285    // same number in the file, whatever bits produced them.
286    let mut by_key: HashMap<[String; 3], u32> = HashMap::new();
287    let mut remap: Vec<u32> = Vec::with_capacity(source_vertices);
288    let mut vertices: Vec<[String; 3]> = Vec::new();
289    for point in solid.mesh.positions.chunks_exact(3) {
290        let key = [coordinate(point[0])?, coordinate(point[1])?, coordinate(point[2])?];
291        let index = match by_key.get(&key) {
292            Some(index) => *index,
293            None => {
294                let index = vertices.len() as u32;
295                vertices.push(key.clone());
296                by_key.insert(key, index);
297                index
298            }
299        };
300        remap.push(index);
301    }
302    let mut triangles = Vec::with_capacity(solid.mesh.indices.len() / 3);
303    for (triangle, corners) in solid.mesh.indices.chunks_exact(3).enumerate() {
304        let a = remap[corners[0] as usize];
305        let b = remap[corners[1] as usize];
306        let c = remap[corners[2] as usize];
307        if a == b || b == c || a == c {
308            report.degenerate_triangles += 1;
309            continue;
310        }
311        let color = triangle_color(solid, triangle).map(|rgb| palette.intern(rgb));
312        triangles.push(([a, b, c], color));
313    }
314    if triangles.is_empty() {
315        return Ok(None);
316    }
317    Ok(Some(WeldedObject { id, name: solid.name.clone(), vertices, triangles, body }))
318}
319
320/// The extruder slot of palette index `color`, if it has one: slots run
321/// from `first_slot` to [`MAX_PAINT_SLOTS`].
322fn paint_slot(color: u32, first_slot: usize) -> Option<usize> {
323    let slot = color as usize + first_slot;
324    (slot <= MAX_PAINT_SLOTS).then_some(slot)
325}
326
327/// The model part's XML.
328fn model_xml(objects: &[WeldedObject], palette: &Palette, document_name: &str, first_slot: usize) -> String {
329    let mut xml = String::new();
330    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
331    xml.push_str(&format!(
332        "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NAMESPACE}\" \
333         xmlns:slic3rpe=\"{SLIC3RPE_NAMESPACE}\">\n"
334    ));
335    xml.push_str(&format!(
336        "  <metadata name=\"Title\">{}</metadata>\n",
337        xml_escape(document_name)
338    ));
339    xml.push_str("  <metadata name=\"Application\">next.BREP.io</metadata>\n");
340    xml.push_str("  <resources>\n");
341    if !palette.colors.is_empty() {
342        xml.push_str(&format!("    <basematerials id=\"{BASE_MATERIALS_ID}\">\n"));
343        for rgb in &palette.colors {
344            xml.push_str(&format!(
345                "      <base name=\"color_{:02x}{:02x}{:02x}\" displaycolor=\"{}\"/>\n",
346                rgb[0],
347                rgb[1],
348                rgb[2],
349                display_color(*rgb)
350            ));
351        }
352        xml.push_str("    </basematerials>\n");
353    }
354    for object in objects {
355        xml.push_str(&format!(
356            "    <object id=\"{}\" name=\"{}\" type=\"model\"",
357            object.id,
358            xml_escape(&object.name)
359        ));
360        if let Some(body) = object.body {
361            xml.push_str(&format!(" pid=\"{BASE_MATERIALS_ID}\" pindex=\"{body}\""));
362        }
363        xml.push_str(">\n      <mesh>\n        <vertices>\n");
364        for [x, y, z] in &object.vertices {
365            xml.push_str(&format!("          <vertex x=\"{x}\" y=\"{y}\" z=\"{z}\"/>\n"));
366        }
367        xml.push_str("        </vertices>\n        <triangles>\n");
368        for ([a, b, c], color) in &object.triangles {
369            xml.push_str(&format!("          <triangle v1=\"{a}\" v2=\"{b}\" v3=\"{c}\""));
370            if let Some(color) = color {
371                // The slicer painting: the colour's slot, on EVERY coloured
372                // triangle (the slicer has no object default to inherit).
373                if let Some(slot) = paint_slot(*color, first_slot) {
374                    let code = painting_code(slot);
375                    xml.push_str(&format!(
376                        " slic3rpe:mmu_segmentation=\"{code}\" paint_color=\"{code}\""
377                    ));
378                }
379                // A triangle in its body's colour inherits the object's
380                // pid/pindex; only a differently coloured one — or a coloured
381                // triangle of an uncoloured body — states its own.
382                if Some(*color) != object.body {
383                    xml.push_str(&format!(" pid=\"{BASE_MATERIALS_ID}\" p1=\"{color}\""));
384                }
385            }
386            xml.push_str("/>\n");
387        }
388        xml.push_str("        </triangles>\n      </mesh>\n    </object>\n");
389    }
390    xml.push_str("  </resources>\n  <build>\n");
391    for object in objects {
392        xml.push_str(&format!("    <item objectid=\"{}\"/>\n", object.id));
393    }
394    xml.push_str("  </build>\n</model>\n");
395    xml
396}
397
398/// A minimal STORED ZIP: local headers with their data in order, then the
399/// central directory and the end record. Fixed timestamp (1980-01-01 00:00,
400/// the earliest DOS date), no extra fields, no comments, no data descriptors.
401struct ZipWriter {
402    bytes: Vec<u8>,
403    central: Vec<u8>,
404    entries: u16,
405}
406
407/// 1980-01-01 as a DOS date: day 1, month 1, year 0 (+1980).
408const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
409
410impl ZipWriter {
411    fn new() -> Self {
412        Self { bytes: Vec::new(), central: Vec::new(), entries: 0 }
413    }
414
415    fn add(&mut self, name: &str, data: &[u8]) -> Result<(), String> {
416        if data.len() > u32::MAX as usize {
417            return Err(format!("write_3mf: part '{name}' is larger than a ZIP entry can hold"));
418        }
419        let crc = crc32(data);
420        let size = data.len() as u32;
421        let offset = self.bytes.len() as u32;
422        let name_bytes = name.as_bytes();
423
424        let mut local = Vec::with_capacity(30 + name_bytes.len());
425        push_u32(&mut local, 0x0403_4b50);
426        push_u16(&mut local, 20); // version needed: 2.0
427        push_u16(&mut local, 0); // flags
428        push_u16(&mut local, 0); // method: stored
429        push_u16(&mut local, 0); // time
430        push_u16(&mut local, DOS_DATE_1980_01_01);
431        push_u32(&mut local, crc);
432        push_u32(&mut local, size);
433        push_u32(&mut local, size);
434        push_u16(&mut local, name_bytes.len() as u16);
435        push_u16(&mut local, 0); // extra length
436        local.extend_from_slice(name_bytes);
437        self.bytes.extend_from_slice(&local);
438        self.bytes.extend_from_slice(data);
439
440        push_u32(&mut self.central, 0x0201_4b50);
441        push_u16(&mut self.central, 20); // version made by
442        push_u16(&mut self.central, 20); // version needed
443        push_u16(&mut self.central, 0); // flags
444        push_u16(&mut self.central, 0); // method
445        push_u16(&mut self.central, 0); // time
446        push_u16(&mut self.central, DOS_DATE_1980_01_01);
447        push_u32(&mut self.central, crc);
448        push_u32(&mut self.central, size);
449        push_u32(&mut self.central, size);
450        push_u16(&mut self.central, name_bytes.len() as u16);
451        push_u16(&mut self.central, 0); // extra length
452        push_u16(&mut self.central, 0); // comment length
453        push_u16(&mut self.central, 0); // disk number start
454        push_u16(&mut self.central, 0); // internal attributes
455        push_u32(&mut self.central, 0); // external attributes
456        push_u32(&mut self.central, offset);
457        self.central.extend_from_slice(name_bytes);
458        self.entries += 1;
459        Ok(())
460    }
461
462    fn finish(mut self) -> Result<Vec<u8>, String> {
463        let directory_offset = self.bytes.len();
464        let directory_size = self.central.len();
465        if directory_offset + directory_size + 22 > u32::MAX as usize {
466            return Err("write_3mf: the package is larger than a ZIP can hold".into());
467        }
468        self.bytes.extend_from_slice(&self.central);
469        push_u32(&mut self.bytes, 0x0605_4b50);
470        push_u16(&mut self.bytes, 0); // this disk
471        push_u16(&mut self.bytes, 0); // directory disk
472        push_u16(&mut self.bytes, self.entries);
473        push_u16(&mut self.bytes, self.entries);
474        push_u32(&mut self.bytes, directory_size as u32);
475        push_u32(&mut self.bytes, directory_offset as u32);
476        push_u16(&mut self.bytes, 0); // comment length
477        Ok(self.bytes)
478    }
479}
480
481fn push_u16(bytes: &mut Vec<u8>, value: u16) {
482    bytes.extend_from_slice(&value.to_le_bytes());
483}
484
485fn push_u32(bytes: &mut Vec<u8>, value: u32) {
486    bytes.extend_from_slice(&value.to_le_bytes());
487}
488
489/// Write `solids` as one 3MF package in millimetres.
490///
491/// `document_name` becomes the model's `Title` metadata. Solids with no
492/// triangles — before or after welding — are SKIPPED (a `model` object must
493/// enclose volume); a set in which nothing has a triangle is refused, as the
494/// OBJ, STL and GLB lanes refuse an empty scene.
495pub fn write_3mf(
496    solids: &[ThreeMfSolid],
497    document_name: &str,
498) -> Result<(Vec<u8>, ThreeMfWriteReport), String> {
499    let mut report = ThreeMfWriteReport::default();
500    let mut palette = Palette::default();
501    let mut objects = Vec::new();
502    for solid in solids {
503        let id = FIRST_OBJECT_ID + objects.len() as u32;
504        if let Some(object) = weld(solid, id, &mut palette, &mut report)? {
505            objects.push(object);
506        }
507    }
508    if objects.is_empty() {
509        return Err("write_3mf: nothing to export, no solid has a triangle".into());
510    }
511    report.objects = objects.len();
512    report.materials = palette.colors.len();
513    let any_uncolored = objects.iter().any(|o| o.triangles.iter().any(|(_, c)| c.is_none()));
514    let first_slot = if any_uncolored { 2 } else { 1 };
515    report.first_paint_slot = first_slot;
516    let slots = MAX_PAINT_SLOTS + 1 - first_slot;
517    report.paint_slots = palette.colors.len().min(slots);
518    report.unpainted_colors = palette.colors.len().saturating_sub(slots);
519    for object in &objects {
520        report.vertices += object.vertices.len();
521        report.triangles += object.triangles.len();
522        report.colored_triangles += object
523            .triangles
524            .iter()
525            .filter(|(_, color)| color.is_some() && *color != object.body)
526            .count();
527        report.painted_triangles += object
528            .triangles
529            .iter()
530            .filter(|(_, color)| color.is_some_and(|c| paint_slot(c, first_slot).is_some()))
531            .count();
532    }
533    let model = model_xml(&objects, &palette, document_name, first_slot);
534
535    let mut zip = ZipWriter::new();
536    zip.add(CONTENT_TYPES_PART, CONTENT_TYPES_XML.as_bytes())?;
537    zip.add(RELATIONSHIPS_PART, RELATIONSHIPS_XML.as_bytes())?;
538    zip.add(MODEL_PART, model.as_bytes())?;
539    let bytes = zip.finish()?;
540    report.bytes = bytes.len();
541    Ok((bytes, report))
542}
543