Skip to main content

ogeom_io/
threemf.rs

1//! 3MF: the mesh package, written with its own archive.
2//!
3//! A 3MF file is a ZIP holding three parts: the content-types declaration,
4//! a relationship pointing at the model, and the model itself: an XML
5//! document of meshes and the items that place them. The mesh half is
6//! ordinary; the archive is the part a kernel usually reaches for a library
7//! to do, and this does not.
8//!
9//! It writes *stored* entries (no compression), which the ZIP format has
10//! always allowed and every reader accepts: what a CAD kernel needs to
11//! write a ZIP is the container, not the codec. A file written this way is
12//! larger than one deflated, and says so by being what it is.
13//!
14//! Reading is the other way round. Every package a slicer or a modelling
15//! tool writes is deflated, so [`read_3mf`] inflates, with the decoder half
16//! of DEFLATE kept in this crate, and reads the archive through its central
17//! directory, where the sizes are, since a streamed entry leaves them out
18//! of its local header.
19
20use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
21use ogeom_math::{Point, Vector};
22use ogeom_topo::Triangulation;
23use std::collections::HashMap;
24use std::fmt::Write as _;
25
26use crate::xml;
27
28/// One mesh in the package, with the name the item carries.
29#[derive(Debug, Clone)]
30pub struct Object<'a> {
31    /// The tessellation.
32    pub mesh: &'a Triangulation,
33    /// The name the object is given, if any.
34    pub name: Option<String>,
35}
36
37/// Write meshes as a 3MF package.
38///
39/// One object per mesh, one item per object, all in millimetres, the
40/// format's own default unit and this kernel's.
41#[must_use]
42pub fn write_3mf(objects: &[Object<'_>]) -> Vec<u8> {
43    let content_types = r#"<?xml version="1.0" encoding="UTF-8"?>
44<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
45  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
46  <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
47</Types>
48"#;
49    let relationships = r#"<?xml version="1.0" encoding="UTF-8"?>
50<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
51  <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
52</Relationships>
53"#;
54
55    let mut model = String::from(
56        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<model unit=\"millimeter\" \
57         xml:lang=\"en-US\" \
58         xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\">\n  \
59         <resources>\n",
60    );
61    let mut items = String::new();
62    for (i, object) in objects.iter().enumerate() {
63        if object.mesh.triangles.is_empty() {
64            continue;
65        }
66        let id = i + 1;
67        match &object.name {
68            Some(name) => {
69                let _ = writeln!(
70                    model,
71                    "    <object id=\"{id}\" type=\"model\" name=\"{}\">",
72                    escaped(name)
73                );
74            }
75            None => {
76                let _ = writeln!(model, "    <object id=\"{id}\" type=\"model\">");
77            }
78        }
79        model.push_str("      <mesh>\n        <vertices>\n");
80        for p in &object.mesh.positions {
81            let _ = writeln!(
82                model,
83                "          <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>",
84                p.x, p.y, p.z
85            );
86        }
87        model.push_str("        </vertices>\n        <triangles>\n");
88        for [a, b, c] in &object.mesh.triangles {
89            let _ = writeln!(
90                model,
91                "          <triangle v1=\"{a}\" v2=\"{b}\" v3=\"{c}\"/>"
92            );
93        }
94        model.push_str("        </triangles>\n      </mesh>\n    </object>\n");
95        let _ = writeln!(items, "    <item objectid=\"{id}\"/>");
96    }
97    model.push_str("  </resources>\n  <build>\n");
98    model.push_str(&items);
99    model.push_str("  </build>\n</model>\n");
100
101    archive(&[
102        ("[Content_Types].xml", content_types.as_bytes()),
103        ("_rels/.rels", relationships.as_bytes()),
104        ("3D/3dmodel.model", model.as_bytes()),
105    ])
106}
107
108/// The XML text escapes, which are the five the specification names.
109fn escaped(text: &str) -> String {
110    text.replace('&', "&amp;")
111        .replace('<', "&lt;")
112        .replace('>', "&gt;")
113        .replace('"', "&quot;")
114        .replace('\'', "&apos;")
115}
116
117/// A ZIP archive of stored (uncompressed) entries.
118///
119/// Local header, data, then a central directory and its end record. Every
120/// field the format requires and none it does not: no data descriptors (the
121/// sizes are known before writing), no ZIP64 (a 3MF over four gigabytes is
122/// a different problem), no extra fields.
123fn archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
124    let mut out: Vec<u8> = Vec::new();
125    let mut directory: Vec<u8> = Vec::new();
126    let mut count = 0_u16;
127
128    for (name, data) in entries {
129        let offset = u32::try_from(out.len()).unwrap_or(u32::MAX);
130        let crc = crc32(data);
131        let size = u32::try_from(data.len()).unwrap_or(u32::MAX);
132        let name_bytes = name.as_bytes();
133        let name_len = u16::try_from(name_bytes.len()).unwrap_or(u16::MAX);
134
135        // Local file header.
136        out.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
137        out.extend_from_slice(&20_u16.to_le_bytes()); // version needed
138        out.extend_from_slice(&0_u16.to_le_bytes()); // flags
139        out.extend_from_slice(&0_u16.to_le_bytes()); // stored
140        out.extend_from_slice(&0_u16.to_le_bytes()); // time
141        out.extend_from_slice(&0_u16.to_le_bytes()); // date
142        out.extend_from_slice(&crc.to_le_bytes());
143        out.extend_from_slice(&size.to_le_bytes());
144        out.extend_from_slice(&size.to_le_bytes());
145        out.extend_from_slice(&name_len.to_le_bytes());
146        out.extend_from_slice(&0_u16.to_le_bytes()); // extra length
147        out.extend_from_slice(name_bytes);
148        out.extend_from_slice(data);
149
150        // Central directory entry.
151        directory.extend_from_slice(&0x0201_4b50_u32.to_le_bytes());
152        directory.extend_from_slice(&20_u16.to_le_bytes()); // version made by
153        directory.extend_from_slice(&20_u16.to_le_bytes()); // version needed
154        directory.extend_from_slice(&0_u16.to_le_bytes());
155        directory.extend_from_slice(&0_u16.to_le_bytes());
156        directory.extend_from_slice(&0_u16.to_le_bytes());
157        directory.extend_from_slice(&0_u16.to_le_bytes());
158        directory.extend_from_slice(&crc.to_le_bytes());
159        directory.extend_from_slice(&size.to_le_bytes());
160        directory.extend_from_slice(&size.to_le_bytes());
161        directory.extend_from_slice(&name_len.to_le_bytes());
162        directory.extend_from_slice(&0_u16.to_le_bytes()); // extra
163        directory.extend_from_slice(&0_u16.to_le_bytes()); // comment
164        directory.extend_from_slice(&0_u16.to_le_bytes()); // disk
165        directory.extend_from_slice(&0_u16.to_le_bytes()); // internal attrs
166        directory.extend_from_slice(&0_u32.to_le_bytes()); // external attrs
167        directory.extend_from_slice(&offset.to_le_bytes());
168        directory.extend_from_slice(name_bytes);
169        count += 1;
170    }
171
172    let directory_offset = u32::try_from(out.len()).unwrap_or(u32::MAX);
173    let directory_size = u32::try_from(directory.len()).unwrap_or(u32::MAX);
174    out.extend_from_slice(&directory);
175    // End of central directory.
176    out.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
177    out.extend_from_slice(&0_u16.to_le_bytes()); // this disk
178    out.extend_from_slice(&0_u16.to_le_bytes()); // directory's disk
179    out.extend_from_slice(&count.to_le_bytes());
180    out.extend_from_slice(&count.to_le_bytes());
181    out.extend_from_slice(&directory_size.to_le_bytes());
182    out.extend_from_slice(&directory_offset.to_le_bytes());
183    out.extend_from_slice(&0_u16.to_le_bytes()); // comment length
184    out
185}
186
187/// The ZIP checksum: CRC-32, reflected, polynomial `0xEDB8_8320`, a byte at
188/// a time through its table.
189fn crc32(data: &[u8]) -> u32 {
190    const TABLE: [u32; 256] = {
191        let mut table = [0_u32; 256];
192        let mut i = 0_u32;
193        while i < 256 {
194            let mut crc = i;
195            let mut k = 0;
196            while k < 8 {
197                crc = if crc & 1 != 0 {
198                    (crc >> 1) ^ 0xEDB8_8320
199                } else {
200                    crc >> 1
201                };
202                k += 1;
203            }
204            table[i as usize] = crc;
205            i += 1;
206        }
207        table
208    };
209    let mut crc = 0xFFFF_FFFF_u32;
210    for byte in data {
211        crc = (crc >> 8) ^ TABLE[((crc ^ u32::from(*byte)) & 0xFF) as usize];
212    }
213    !crc
214}
215
216// --- Reading -----------------------------------------------------------------
217
218/// One entry of the archive's central directory.
219struct Entry {
220    name: String,
221    method: u16,
222    flags: u16,
223    crc: u32,
224    compressed: usize,
225    size: usize,
226    header: usize,
227}
228
229fn u16_at(bytes: &[u8], at: usize) -> OgeomResult<u16> {
230    match bytes.get(at..at + 2) {
231        Some(b) => Ok(u16::from_le_bytes([b[0], b[1]])),
232        None => ogeom_bail!(Construction, "the archive is cut short"),
233    }
234}
235
236fn u32_at(bytes: &[u8], at: usize) -> OgeomResult<u32> {
237    match bytes.get(at..at + 4) {
238        Some(b) => Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])),
239        None => ogeom_bail!(Construction, "the archive is cut short"),
240    }
241}
242
243/// The central directory: every entry, with the sizes a streamed local
244/// header leaves as zero.
245fn directory(bytes: &[u8]) -> OgeomResult<Vec<Entry>> {
246    const END: u32 = 0x0605_4b50;
247    const LOCATOR: u32 = 0x0706_4b50;
248    const END64: u32 = 0x0606_4b50;
249    // The end record is the last thing in the file but for a comment of up
250    // to 65 535 bytes.
251    let floor = bytes.len().saturating_sub(22 + 0xFFFF);
252    let mut end = None;
253    let mut at = bytes.len().saturating_sub(22);
254    while at >= floor && bytes.len() >= 22 {
255        if u32_at(bytes, at)? == END {
256            end = Some(at);
257            break;
258        }
259        if at == 0 {
260            break;
261        }
262        at -= 1;
263    }
264    let Some(end) = end else {
265        ogeom_bail!(Construction, "these bytes are not a ZIP archive");
266    };
267    let zip64 = end >= 20 && u32_at(bytes, end - 20)? == LOCATOR;
268    // In a ZIP64 archive the classic record's disk numbers may be
269    // saturated like the rest of it; the ZIP64 record's are the ones read.
270    if !zip64 && (u16_at(bytes, end + 4)? != 0 || u16_at(bytes, end + 6)? != 0) {
271        ogeom_bail!(Construction, "the archive spans several disks");
272    }
273    let mut count = u64::from(u16_at(bytes, end + 10)?);
274    let mut offset = u64::from(u32_at(bytes, end + 16)?);
275    // A ZIP64 archive keeps the classic end record with its fields
276    // saturated, and the true ones in a ZIP64 end record its locator, just
277    // before the classic one, points to. Streaming writers emit it whatever
278    // the archive's size.
279    if zip64 {
280        let at = to_usize(u64_at(bytes, end - 20 + 8)?)?;
281        if u32_at(bytes, at)? != END64 {
282            ogeom_bail!(
283                Construction,
284                "the archive's ZIP64 locator points at no ZIP64 end record"
285            );
286        }
287        if u32_at(bytes, at + 16)? != 0 || u32_at(bytes, at + 20)? != 0 {
288            ogeom_bail!(Construction, "the archive spans several disks");
289        }
290        count = u64_at(bytes, at + 32)?;
291        offset = u64_at(bytes, at + 48)?;
292    } else if count == 0xFFFF || offset == 0xFFFF_FFFF {
293        ogeom_bail!(
294            Construction,
295            "the archive's end record is saturated and no ZIP64 record follows it"
296        );
297    }
298    let mut entries = Vec::new();
299    let mut at = to_usize(offset)?;
300    for _ in 0..count {
301        if u32_at(bytes, at)? != 0x0201_4b50 {
302            ogeom_bail!(Construction, "the archive's central directory is damaged");
303        }
304        let name_len = usize::from(u16_at(bytes, at + 28)?);
305        let extra_len = usize::from(u16_at(bytes, at + 30)?);
306        let comment_len = usize::from(u16_at(bytes, at + 32)?);
307        let Some(name) = bytes.get(at + 46..at + 46 + name_len) else {
308            ogeom_bail!(Construction, "the archive is cut short");
309        };
310        let name = String::from_utf8_lossy(name).into_owned();
311        let mut size = u64::from(u32_at(bytes, at + 24)?);
312        let mut compressed = u64::from(u32_at(bytes, at + 20)?);
313        let mut header = u64::from(u32_at(bytes, at + 42)?);
314        // A saturated size or offset is carried in the ZIP64 extra field,
315        // in the order uncompressed size, compressed size, header offset,
316        // each present only where its fixed field is saturated.
317        if [size, compressed, header].contains(&0xFFFF_FFFF) {
318            let Some(extra) = extra_field(bytes, at + 46 + name_len, extra_len, 0x0001) else {
319                ogeom_bail!(
320                    Construction,
321                    "the entry {name} has a saturated field and no ZIP64 extra"
322                );
323            };
324            let mut read = 0;
325            for field in [&mut size, &mut compressed, &mut header] {
326                if *field == 0xFFFF_FFFF {
327                    *field = u64_at(extra, read)?;
328                    read += 8;
329                }
330            }
331        }
332        entries.push(Entry {
333            name,
334            flags: u16_at(bytes, at + 8)?,
335            method: u16_at(bytes, at + 10)?,
336            crc: u32_at(bytes, at + 16)?,
337            compressed: to_usize(compressed)?,
338            size: to_usize(size)?,
339            header: to_usize(header)?,
340        });
341        at += 46 + name_len + extra_len + comment_len;
342    }
343    Ok(entries)
344}
345
346fn u64_at(bytes: &[u8], at: usize) -> OgeomResult<u64> {
347    match bytes.get(at..at + 8) {
348        Some(b) => Ok(u64::from_le_bytes([
349            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
350        ])),
351        None => ogeom_bail!(Construction, "the archive is cut short"),
352    }
353}
354
355fn to_usize(value: u64) -> OgeomResult<usize> {
356    match usize::try_from(value) {
357        Ok(v) => Ok(v),
358        Err(_) => ogeom_bail!(
359            Construction,
360            "the archive names an offset past this machine's reach"
361        ),
362    }
363}
364
365/// The data of the extra field with header `id` among the `len` bytes of
366/// extra fields starting at `at`.
367fn extra_field(bytes: &[u8], at: usize, len: usize, id: u16) -> Option<&[u8]> {
368    let fields = bytes.get(at..at + len)?;
369    let mut i = 0;
370    while i + 4 <= fields.len() {
371        let tag = u16::from_le_bytes([fields[i], fields[i + 1]]);
372        let size = usize::from(u16::from_le_bytes([fields[i + 2], fields[i + 3]]));
373        let data = fields.get(i + 4..i + 4 + size)?;
374        if tag == id {
375            return Some(data);
376        }
377        i += 4 + size;
378    }
379    None
380}
381
382/// An entry's bytes: stored ones as they are, deflated ones inflated, and
383/// either checked against the checksum the archive gives.
384fn contents(bytes: &[u8], entry: &Entry) -> OgeomResult<Vec<u8>> {
385    let name = &entry.name;
386    if entry.flags & 1 != 0 {
387        ogeom_bail!(Construction, "the entry {name} is encrypted");
388    }
389    if u32_at(bytes, entry.header)? != 0x0403_4b50 {
390        ogeom_bail!(
391            Construction,
392            "the entry {name} has no local header where the directory puts it"
393        );
394    }
395    let name_len = usize::from(u16_at(bytes, entry.header + 26)?);
396    let extra_len = usize::from(u16_at(bytes, entry.header + 28)?);
397    let data_at = entry.header + 30 + name_len + extra_len;
398    let Some(data) = bytes.get(data_at..data_at + entry.compressed) else {
399        ogeom_bail!(
400            Construction,
401            "the entry {name} runs past the end of the file"
402        );
403    };
404    let out = match entry.method {
405        0 => data.to_vec(),
406        8 => crate::inflate::inflate(data, entry.size)?,
407        method => ogeom_bail!(
408            Construction,
409            "the entry {name} is compressed with method {method}; only stored and deflated entries are read"
410        ),
411    };
412    if out.len() != entry.size || crc32(&out) != entry.crc {
413        ogeom_bail!(Construction, "the entry {name} does not match its checksum");
414    }
415    Ok(out)
416}
417
418/// Read every part of a package: the entry names and their bytes.
419///
420/// Stored and deflated entries are read, each checked against its
421/// checksum, from classic and ZIP64 archives alike; the central directory's
422/// sizes are the ones trusted, since a streamed entry's local header leaves
423/// them zero or saturated. Any other compression method, an encrypted
424/// entry, or an archive spanning several disks is refused by name rather
425/// than half-read.
426///
427/// # Errors
428///
429/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
430/// bytes are not a ZIP archive, an entry is damaged, or it is one of the
431/// refusals above.
432pub fn read_package(bytes: &[u8]) -> OgeomResult<Vec<(String, Vec<u8>)>> {
433    directory(bytes)?
434        .iter()
435        .filter(|e| !e.name.ends_with('/'))
436        .map(|e| Ok((e.name.clone(), contents(bytes, e)?)))
437        .collect()
438}
439
440/// What a 3MF object is for, as its `type` attribute says.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum ObjectType {
443    /// A part to be made: `model`, the default.
444    Model,
445    /// A support the designer modelled as solid: `solidsupport`.
446    SolidSupport,
447    /// Support structure, not part of the design: `support`.
448    Support,
449    /// An open surface, not a solid: `surface`.
450    Surface,
451    /// Anything else the file carries: `other`.
452    Other,
453}
454
455/// One object of a package, placed as its build item places it.
456#[derive(Debug, Clone)]
457pub struct ThreeMfObject {
458    /// The object's name, where it has one.
459    pub name: Option<String>,
460    /// What the object is for. Every type is read; the import's warnings
461    /// say when one is not a part.
462    pub object_type: ObjectType,
463    /// The mesh in millimetres, components flattened into it and the build
464    /// item's transform applied.
465    pub mesh: Triangulation,
466    /// The object's colour as the file writes it (sRGB, RGBA in `[0, 1]`),
467    /// where every triangle carries the same one.
468    pub colour: Option<[f64; 4]>,
469}
470
471/// What [`read_3mf`] found.
472#[derive(Debug, Clone)]
473pub struct ThreeMfImport {
474    /// One object per build item, in build order.
475    pub objects: Vec<ThreeMfObject>,
476    /// What was read with a caveat, or skipped: required extensions this
477    /// reader does not know, per-triangle colours dropped, objects that are
478    /// not parts.
479    pub warnings: Vec<String>,
480}
481
482const CORE: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
483const MATERIAL: &str = "http://schemas.microsoft.com/3dmanufacturing/material/2015/02";
484const PRODUCTION: &str = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06";
485const MODEL_RELATIONSHIP: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel";
486/// The extensions whose required use changes nothing this reader returns:
487/// production (read; it is how multi-part packages reference their
488/// objects), materials (read as far as colour), and the slicers' own.
489const UNDERSTOOD: [&str; 2] = [PRODUCTION, MATERIAL];
490
491/// Read a 3MF package into placed meshes.
492///
493/// The start part is the one `_rels/.rels` names as the 3D model. Its
494/// objects are meshes or assemblies of components; a component, or a
495/// build item, may name an object in another model part of the package
496/// through the production extension's `path`, as slicers write every
497/// object to its own part. Each build item comes back as one mesh, its
498/// components flattened and every transform applied, scaled from the
499/// model's unit to millimetres. A transform that mirrors has its triangles
500/// rewound, so every mesh keeps its outward winding. Vertices the
501/// flattening brings together within the confusion tolerance are welded.
502///
503/// A uniform object colour is kept, from a base material or a colour
504/// group; colours that vary across the triangles are dropped with a
505/// warning, as are texture and composite properties.
506///
507/// # Errors
508///
509/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
510/// archive cannot be read (see [`read_package`]), a model part is not
511/// well-formed, a reference names an object that is not there or contains
512/// itself, a triangle names a vertex that is not there, or the model
513/// requires the secure-content extension.
514pub fn read_3mf(bytes: &[u8], tol: Tolerances) -> OgeomResult<ThreeMfImport> {
515    let entries = directory(bytes)?;
516    let find = |path: &str| -> Option<&Entry> {
517        let wanted = part_key(path);
518        entries.iter().find(|e| part_key(&e.name) == wanted)
519    };
520    let mut warnings = Vec::new();
521    let start = match find("_rels/.rels") {
522        Some(rels) => {
523            let text = String::from_utf8_lossy(&contents(bytes, rels)?).into_owned();
524            start_part(&text)?
525        }
526        None => None,
527    }
528    .unwrap_or_else(|| "/3D/3dmodel.model".to_owned());
529
530    let mut parts: HashMap<String, Part> = HashMap::new();
531    let mut pending = vec![start.clone()];
532    while let Some(path) = pending.pop() {
533        let key = part_key(&path);
534        if parts.contains_key(&key) {
535            continue;
536        }
537        let Some(entry) = find(&path) else {
538            ogeom_bail!(Construction, "the package has no model part {path}");
539        };
540        let raw = contents(bytes, entry)?;
541        let Ok(text) = std::str::from_utf8(&raw) else {
542            ogeom_bail!(Construction, "the model part {path} is not UTF-8");
543        };
544        let part = parse_part(text, &path, &mut warnings)?;
545        pending.extend(part.referenced());
546        parts.insert(key, part);
547    }
548
549    let root = &parts[&part_key(&start)];
550    let scale = root.scale;
551    let mut objects = Vec::with_capacity(root.build.len());
552    for item in &root.build {
553        let path = item.path.clone().unwrap_or_else(|| start.clone());
554        let mut flat = Flat::default();
555        flatten(&parts, &path, item.object, item.transform, &mut flat, 0)?;
556        let top = object(&parts, &path, item.object)?;
557        let name = top.name.clone();
558        let label = name
559            .clone()
560            .unwrap_or_else(|| format!("object {}", item.object));
561        match top.object_type {
562            ObjectType::Model | ObjectType::SolidSupport => {}
563            ObjectType::Support => warnings.push(format!("{label} is a support structure")),
564            ObjectType::Surface => warnings.push(format!("{label} is a surface, not a solid")),
565            ObjectType::Other => warnings.push(format!("{label} is of type other, not a part")),
566        }
567        if flat.skipped_properties {
568            warnings.push(format!(
569                "{label} carries textures or composite materials; they are not read"
570            ));
571        }
572        let colour = match flat.colours.as_slice() {
573            [] => None,
574            [only] => *only,
575            _ => {
576                warnings.push(format!(
577                    "{label} carries several colours; the per-triangle colours are dropped"
578                ));
579                resolve_colour(parts.get(&part_key(&path)), top.pid, top.pindex)
580            }
581        };
582        if flat.degenerate > 0 {
583            warnings.push(format!(
584                "{label}: {} triangles repeat a vertex and were dropped",
585                flat.degenerate
586            ));
587        }
588        let mesh = flat.into_mesh(scale, tol.confusion());
589        objects.push(ThreeMfObject {
590            name,
591            object_type: top.object_type,
592            mesh,
593            colour,
594        });
595    }
596    Ok(ThreeMfImport { objects, warnings })
597}
598
599/// A part name as the archive and every reference compare it: without the
600/// leading slash, percent escapes decoded, and case folded, as the package
601/// conventions make part names case-insensitive.
602fn part_key(path: &str) -> String {
603    let path = path.trim_start_matches('/');
604    let bytes = path.as_bytes();
605    let mut out = Vec::with_capacity(bytes.len());
606    let mut i = 0;
607    while i < bytes.len() {
608        if bytes[i] == b'%'
609            && let Some(hex) = path.get(i + 1..i + 3)
610            && let Ok(byte) = u8::from_str_radix(hex, 16)
611        {
612            out.push(byte);
613            i += 3;
614            continue;
615        }
616        out.push(bytes[i]);
617        i += 1;
618    }
619    String::from_utf8_lossy(&out).to_lowercase()
620}
621
622/// The target of the package's 3D-model relationship.
623fn start_part(rels: &str) -> OgeomResult<Option<String>> {
624    let mut reader = xml::Reader::new(rels);
625    while let Some(event) = reader.next()? {
626        if let xml::Event::Start(start) = event
627            && start.local == "Relationship"
628            && start.get("Type") == Some(MODEL_RELATIONSHIP)
629            && let Some(target) = start.get("Target")
630        {
631            return Ok(Some(target.to_owned()));
632        }
633    }
634    Ok(None)
635}
636
637/// A 3MF transform: the three rows the format writes, image of each axis,
638/// then the translation; a point is carried as a row vector.
639#[derive(Debug, Clone, Copy)]
640struct Affine {
641    axes: [Vector; 3],
642    translation: Vector,
643}
644
645impl Affine {
646    const IDENTITY: Self = Self {
647        axes: [Vector::X, Vector::Y, Vector::Z],
648        translation: Vector::ZERO,
649    };
650
651    fn parse(text: &str) -> OgeomResult<Self> {
652        let values: Vec<f64> = text
653            .split_ascii_whitespace()
654            .map(str::parse)
655            .collect::<Result<_, _>>()
656            .or_else(|_| ogeom_bail!(Construction, "the transform {text} is not twelve numbers"))?;
657        let [a, b, c, d, e, f, g, h, i, x, y, z] = values[..] else {
658            ogeom_bail!(Construction, "the transform {text} is not twelve numbers");
659        };
660        Ok(Self {
661            axes: [
662                Vector::new(a, b, c),
663                Vector::new(d, e, f),
664                Vector::new(g, h, i),
665            ],
666            translation: Vector::new(x, y, z),
667        })
668    }
669
670    fn vector(self, v: Vector) -> Vector {
671        self.axes[0] * v.x + self.axes[1] * v.y + self.axes[2] * v.z
672    }
673
674    fn point(self, p: Point) -> Point {
675        Point::ORIGIN + self.vector(p - Point::ORIGIN) + self.translation
676    }
677
678    /// `inner` first, then `self`.
679    fn after(self, inner: Self) -> Self {
680        Self {
681            axes: inner.axes.map(|a| self.vector(a)),
682            translation: self.vector(inner.translation) + self.translation,
683        }
684    }
685
686    fn mirrors(self) -> bool {
687        self.axes[0].dot(self.axes[1].cross(self.axes[2])) < 0.0
688    }
689}
690
691/// A component or a build item: an object, where it lives, and where it
692/// is placed.
693struct Reference {
694    path: Option<String>,
695    object: u32,
696    transform: Affine,
697}
698
699enum Content {
700    Mesh {
701        positions: Vec<Point>,
702        triangles: Vec<[u32; 3]>,
703        /// Each triangle's property, `(group, index)`, where it names one.
704        properties: Vec<Option<(u32, u32)>>,
705    },
706    Components(Vec<Reference>),
707}
708
709struct ObjectDef {
710    name: Option<String>,
711    object_type: ObjectType,
712    pid: Option<u32>,
713    pindex: Option<u32>,
714    content: Content,
715}
716
717/// One model part: its objects, property groups and build.
718struct Part {
719    scale: f64,
720    objects: HashMap<u32, ObjectDef>,
721    /// Colour groups and base materials by id; `None` for a property
722    /// group this reader does not read.
723    groups: HashMap<u32, Option<Vec<[f64; 4]>>>,
724    build: Vec<Reference>,
725}
726
727impl Part {
728    /// The other parts this one's components and items name.
729    fn referenced(&self) -> Vec<String> {
730        let components = self.objects.values().flat_map(|o| match &o.content {
731            Content::Components(c) => c.iter().collect::<Vec<_>>(),
732            Content::Mesh { .. } => Vec::new(),
733        });
734        components
735            .chain(&self.build)
736            .filter_map(|r| r.path.clone())
737            .collect()
738    }
739}
740
741fn number<T: std::str::FromStr>(start: &xml::Start<'_>, name: &str) -> OgeomResult<Option<T>> {
742    match start.get(name) {
743        None => Ok(None),
744        Some(text) => match text.trim().parse() {
745            Ok(v) => Ok(Some(v)),
746            Err(_) => ogeom_bail!(
747                Construction,
748                "the {} attribute {name}=\"{text}\" is not a number",
749                start.local
750            ),
751        },
752    }
753}
754
755fn required<T: std::str::FromStr>(start: &xml::Start<'_>, name: &str) -> OgeomResult<T> {
756    match number(start, name)? {
757        Some(v) => Ok(v),
758        None => ogeom_bail!(Construction, "a {} has no {name}", start.local),
759    }
760}
761
762fn parse_part(text: &str, path: &str, warnings: &mut Vec<String>) -> OgeomResult<Part> {
763    let mut reader = xml::Reader::new(text);
764    let mut part = Part {
765        scale: 1.0,
766        objects: HashMap::new(),
767        groups: HashMap::new(),
768        build: Vec::new(),
769    };
770    // Where the reader is: the element names from the model down.
771    let mut stack: Vec<(bool, String)> = Vec::new();
772    let mut current: Option<(u32, ObjectDef)> = None;
773    let mut group: Option<(u32, Vec<[f64; 4]>)> = None;
774    while let Some(event) = reader.next()? {
775        let start = match event {
776            xml::Event::End => {
777                if let Some((core, name)) = stack.pop() {
778                    match (core, name.as_str()) {
779                        (true, "object") => {
780                            if let Some((id, def)) = current.take() {
781                                part.objects.insert(id, def);
782                            }
783                        }
784                        (_, "basematerials" | "colorgroup") => {
785                            if let Some((id, colours)) = group.take() {
786                                part.groups.insert(id, Some(colours));
787                            }
788                        }
789                        _ => {}
790                    }
791                }
792                continue;
793            }
794            xml::Event::Start(start) => start,
795        };
796        let ns = start.namespace.as_ref();
797        let core = ns == CORE;
798        match (ns, start.local, stack.len()) {
799            (CORE, "model", 0) => {
800                part.scale = unit_scale(start.get("unit").unwrap_or("millimeter"))?;
801                for prefix in start
802                    .get("requiredextensions")
803                    .unwrap_or("")
804                    .split_whitespace()
805                {
806                    let uri = namespace_of(text, prefix);
807                    if uri.contains("securecontent") {
808                        ogeom_bail!(
809                            Construction,
810                            "the model {path} requires the secure-content extension; its meshes are encrypted"
811                        );
812                    }
813                    if !UNDERSTOOD.contains(&uri.as_str()) {
814                        warnings.push(format!(
815                            "the model {path} requires the extension {uri}, which is not read"
816                        ));
817                    }
818                }
819            }
820            (_, _, 0) => ogeom_bail!(Construction, "the part {path} is not a 3MF model"),
821            (CORE, "object", _) => {
822                let id = required(&start, "id")?;
823                let object_type = match start.get("type").unwrap_or("model") {
824                    "model" => ObjectType::Model,
825                    "solidsupport" => ObjectType::SolidSupport,
826                    "support" => ObjectType::Support,
827                    "surface" => ObjectType::Surface,
828                    _ => ObjectType::Other,
829                };
830                current = Some((
831                    id,
832                    ObjectDef {
833                        name: start.get("name").map(str::to_owned),
834                        object_type,
835                        pid: number(&start, "pid")?,
836                        pindex: number(&start, "pindex")?,
837                        content: Content::Components(Vec::new()),
838                    },
839                ));
840            }
841            (CORE, "mesh", _) => {
842                if let Some((_, def)) = current.as_mut() {
843                    def.content = Content::Mesh {
844                        positions: Vec::new(),
845                        triangles: Vec::new(),
846                        properties: Vec::new(),
847                    };
848                }
849            }
850            (CORE, "vertex", _) => {
851                if let Some((_, def)) = current.as_mut()
852                    && let Content::Mesh { positions, .. } = &mut def.content
853                {
854                    positions.push(Point::new(
855                        required(&start, "x")?,
856                        required(&start, "y")?,
857                        required(&start, "z")?,
858                    ));
859                }
860            }
861            (CORE, "triangle", _) => {
862                if let Some((_, def)) = current.as_mut() {
863                    let pid = number(&start, "pid")?.or(def.pid);
864                    let index = number(&start, "p1")?.or(def.pindex);
865                    if let Content::Mesh {
866                        triangles,
867                        properties,
868                        ..
869                    } = &mut def.content
870                    {
871                        triangles.push([
872                            required(&start, "v1")?,
873                            required(&start, "v2")?,
874                            required(&start, "v3")?,
875                        ]);
876                        properties.push(pid.zip(index));
877                    }
878                }
879            }
880            (CORE, "component", _) => {
881                let reference = reference(&start)?;
882                if let Some((_, def)) = current.as_mut()
883                    && let Content::Components(list) = &mut def.content
884                {
885                    list.push(reference);
886                }
887            }
888            (CORE, "item", _) => part.build.push(reference(&start)?),
889            (CORE, "basematerials", _) | (MATERIAL, "colorgroup", _) => {
890                group = Some((required(&start, "id")?, Vec::new()));
891            }
892            (CORE, "base", _) => {
893                if let Some((_, colours)) = group.as_mut() {
894                    colours.push(parse_colour(
895                        start.get("displaycolor").unwrap_or("#FFFFFF"),
896                    )?);
897                }
898            }
899            (MATERIAL, "color", _) => {
900                if let Some((_, colours)) = group.as_mut() {
901                    colours.push(parse_colour(start.get("color").unwrap_or("#FFFFFF"))?);
902                }
903            }
904            (MATERIAL, _, _) => {
905                // Textures, composites and multi-properties: their ids are
906                // known, so a triangle naming one is reported, not failed.
907                if let Some(id) = number::<u32>(&start, "id")? {
908                    part.groups.insert(id, None);
909                }
910                if !start.empty {
911                    reader.skip()?;
912                    continue;
913                }
914            }
915            (CORE, "metadata" | "metadatagroup", _) if !start.empty => {
916                reader.skip()?;
917                continue;
918            }
919            _ => {}
920        }
921        // An empty element still ends, and its end pops it.
922        stack.push((core, start.local.to_owned()));
923    }
924    Ok(part)
925}
926
927fn reference(start: &xml::Start<'_>) -> OgeomResult<Reference> {
928    Ok(Reference {
929        path: start.get_in(PRODUCTION, "path").map(str::to_owned),
930        object: required(start, "objectid")?,
931        transform: match start.get("transform") {
932            Some(text) => Affine::parse(text)?,
933            None => Affine::IDENTITY,
934        },
935    })
936}
937
938/// The URI a prefix is declared as on the model element, for naming a
939/// required extension.
940fn namespace_of(text: &str, prefix: &str) -> String {
941    let declaration = format!("xmlns:{prefix}=");
942    text.find(&declaration)
943        .and_then(|i| {
944            let rest = &text[i + declaration.len()..];
945            let quote = rest.chars().next()?;
946            let rest = &rest[1..];
947            rest.find(quote).map(|end| rest[..end].to_owned())
948        })
949        .unwrap_or_else(|| prefix.to_owned())
950}
951
952fn unit_scale(unit: &str) -> OgeomResult<f64> {
953    Ok(match unit {
954        "micron" => 1e-3,
955        "millimeter" => 1.0,
956        "centimeter" => 10.0,
957        "inch" => 25.4,
958        "foot" => 304.8,
959        "meter" => 1000.0,
960        _ => ogeom_bail!(Construction, "the model unit {unit} is not one 3MF defines"),
961    })
962}
963
964/// `#RRGGBB` or `#RRGGBBAA`, to RGBA in `[0, 1]`.
965fn parse_colour(text: &str) -> OgeomResult<[f64; 4]> {
966    let hex = text.trim().trim_start_matches('#');
967    let channel = |i: usize| -> Option<f64> {
968        let byte = u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()?;
969        Some(f64::from(byte) / 255.0)
970    };
971    match (hex.len(), channel(0), channel(2), channel(4)) {
972        (6, Some(r), Some(g), Some(b)) => Ok([r, g, b, 1.0]),
973        (8, Some(r), Some(g), Some(b)) => Ok([r, g, b, channel(6).unwrap_or(1.0)]),
974        _ => ogeom_bail!(
975            Construction,
976            "the colour {text} is not #RRGGBB or #RRGGBBAA"
977        ),
978    }
979}
980
981fn object<'p>(parts: &'p HashMap<String, Part>, path: &str, id: u32) -> OgeomResult<&'p ObjectDef> {
982    match parts.get(&part_key(path)).and_then(|p| p.objects.get(&id)) {
983        Some(def) => Ok(def),
984        None => ogeom_bail!(Construction, "the model {path} has no object {id}"),
985    }
986}
987
988/// A colour a property names: `None` for none, or for a group this reader
989/// does not read.
990fn resolve_colour(part: Option<&Part>, pid: Option<u32>, index: Option<u32>) -> Option<[f64; 4]> {
991    let group = part?.groups.get(&pid?)?.as_ref()?;
992    group.get(index? as usize).copied()
993}
994
995/// The meshes of one build item, gathered as they flatten.
996#[derive(Default)]
997struct Flat {
998    positions: Vec<Point>,
999    triangles: Vec<[u32; 3]>,
1000    /// Every distinct colour a triangle carries, `None` among them for a
1001    /// triangle that carries none.
1002    colours: Vec<Option<[f64; 4]>>,
1003    skipped_properties: bool,
1004    degenerate: usize,
1005}
1006
1007/// Components nest; past this depth a reference is taken to contain itself.
1008const DEPTH: usize = 64;
1009
1010fn flatten(
1011    parts: &HashMap<String, Part>,
1012    path: &str,
1013    id: u32,
1014    placed: Affine,
1015    flat: &mut Flat,
1016    depth: usize,
1017) -> OgeomResult<()> {
1018    if depth > DEPTH {
1019        ogeom_bail!(Construction, "the object {id} in {path} contains itself");
1020    }
1021    let def = object(parts, path, id)?;
1022    let part = parts.get(&part_key(path));
1023    match &def.content {
1024        Content::Components(components) => {
1025            for c in components {
1026                let inner = c.path.as_deref().unwrap_or(path);
1027                flatten(
1028                    parts,
1029                    inner,
1030                    c.object,
1031                    placed.after(c.transform),
1032                    flat,
1033                    depth + 1,
1034                )?;
1035            }
1036        }
1037        Content::Mesh {
1038            positions,
1039            triangles,
1040            properties,
1041        } => {
1042            let base = u32::try_from(flat.positions.len()).unwrap_or(u32::MAX);
1043            let count = positions.len();
1044            flat.positions
1045                .extend(positions.iter().map(|p| placed.point(*p)));
1046            let mirrored = placed.mirrors();
1047            for (triangle, property) in triangles.iter().zip(properties) {
1048                if triangle.iter().any(|&v| v as usize >= count) {
1049                    ogeom_bail!(
1050                        Construction,
1051                        "a triangle of object {id} in {path} names a vertex past the {count} it has"
1052                    );
1053                }
1054                let [a, b, c] = *triangle;
1055                if a == b || b == c || c == a {
1056                    flat.degenerate += 1;
1057                    continue;
1058                }
1059                let triangle = if mirrored { [a, c, b] } else { [a, b, c] };
1060                flat.triangles.push(triangle.map(|v| base + v));
1061                let colour = match property {
1062                    Some((pid, index)) => match part.and_then(|p| p.groups.get(pid)) {
1063                        Some(Some(group)) => group.get(*index as usize).copied(),
1064                        Some(None) => {
1065                            flat.skipped_properties = true;
1066                            None
1067                        }
1068                        None => None,
1069                    },
1070                    None => None,
1071                };
1072                if !flat.colours.contains(&colour) {
1073                    flat.colours.push(colour);
1074                }
1075            }
1076        }
1077    }
1078    Ok(())
1079}
1080
1081impl Flat {
1082    /// The mesh, scaled to millimetres, coincident vertices welded, and a
1083    /// normal at each vertex from the triangles around it.
1084    fn into_mesh(self, scale: f64, weld: f64) -> Triangulation {
1085        let positions: Vec<Point> = self
1086            .positions
1087            .iter()
1088            .map(|p| Point::ORIGIN + (*p - Point::ORIGIN) * scale)
1089            .collect();
1090        // Weld on a grid of the weld distance, looking in the neighbouring
1091        // cells too, so two points either side of a cell wall still meet.
1092        // A cast saturates, so a coordinate past `i64`'s cells shares the
1093        // last one and is still compared by distance.
1094        #[allow(clippy::cast_possible_truncation, reason = "saturating")]
1095        let cell = |p: Point| {
1096            (
1097                (p.x / weld).floor() as i64,
1098                (p.y / weld).floor() as i64,
1099                (p.z / weld).floor() as i64,
1100            )
1101        };
1102        let mut grid: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
1103        let mut kept: Vec<Point> = Vec::with_capacity(positions.len());
1104        let mut remap = Vec::with_capacity(positions.len());
1105        for p in &positions {
1106            let (x, y, z) = cell(*p);
1107            let mut found = None;
1108            'search: for dx in -1..=1 {
1109                for dy in -1..=1 {
1110                    for dz in -1..=1 {
1111                        if let Some(list) = grid.get(&(
1112                            x.saturating_add(dx),
1113                            y.saturating_add(dy),
1114                            z.saturating_add(dz),
1115                        )) && let Some(&k) = list
1116                            .iter()
1117                            .find(|&&k| kept[k as usize].distance(*p) <= weld)
1118                        {
1119                            found = Some(k);
1120                            break 'search;
1121                        }
1122                    }
1123                }
1124            }
1125            let index = found.unwrap_or_else(|| {
1126                let k = u32::try_from(kept.len()).unwrap_or(u32::MAX);
1127                kept.push(*p);
1128                grid.entry((x, y, z)).or_default().push(k);
1129                k
1130            });
1131            remap.push(index);
1132        }
1133        let triangles: Vec<[u32; 3]> = self
1134            .triangles
1135            .iter()
1136            .map(|t| t.map(|v| remap[v as usize]))
1137            .filter(|[a, b, c]| a != b && b != c && c != a)
1138            .collect();
1139        let mut normals = vec![Vector::ZERO; kept.len()];
1140        for triangle in &triangles {
1141            let [a, b, c] = triangle.map(|i| kept[i as usize]);
1142            let face = (b - a).cross(c - a);
1143            for &i in triangle {
1144                normals[i as usize] += face;
1145            }
1146        }
1147        let normals = normals
1148            .into_iter()
1149            .map(|n| {
1150                let m = n.magnitude();
1151                if m > 0.0 { n / m } else { Vector::Z }
1152            })
1153            .collect();
1154        Triangulation {
1155            parameters: vec![(0.0, 0.0); kept.len()],
1156            positions: kept,
1157            normals,
1158            triangles,
1159            deflection_met: true,
1160        }
1161    }
1162}