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("&"),
196 '<' => out.push_str("<"),
197 '>' => out.push_str(">"),
198 '"' => out.push_str("""),
199 '\'' => out.push_str("'"),
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