Skip to main content

falcon_library/model3d/
threemf.rs

1//! 3MF: a zip holding the model as XML.
2//!
3//! Reads the core specification plus the colour parts of the materials
4//! extension: base materials and colour groups, applied per object or per
5//! triangle. Build items and components are flattened into world-space
6//! meshes, one per distinct colour of each object instance.
7
8use std::collections::HashMap;
9use std::io::Read;
10
11use super::math::{self, M4, V3};
12use super::{Material, Mesh, Model, ParseError, Result};
13
14/// Most bytes read from one zip entry, against zip bombs.
15const MAX_PART: u64 = 1 << 30;
16/// Deepest component nesting followed.
17const MAX_DEPTH: usize = 32;
18/// Most object instances made, against component fan-out.
19const MAX_INSTANCES: usize = 100_000;
20
21pub fn parse(bytes: &[u8]) -> Result<Model> {
22    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ParseError(format!("not a 3MF (zip) file: {e}")))?;
23    let name = find_model_part(&mut zip).ok_or_else(|| ParseError::new("the 3MF has no 3D model part"))?;
24    let data = read_part(&mut zip, &name).ok_or_else(|| ParseError(format!("cannot read {name} in the 3MF")))?;
25    let doc = parse_document(&String::from_utf8_lossy(&data));
26    doc.flatten()
27}
28
29fn entry_name(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: &str) -> Option<String> {
30    let wanted = wanted.trim_start_matches('/');
31    (0..zip.len()).find_map(|i| {
32        let f = zip.by_index_raw(i).ok()?;
33        let full = String::from_utf8_lossy(f.name_raw()).into_owned();
34        (full.trim_start_matches('/').eq_ignore_ascii_case(wanted) && !f.is_dir()).then_some(full)
35    })
36}
37
38fn read_part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
39    let real = entry_name(zip, name)?;
40    let f = zip.by_name(&real).ok()?;
41    let mut out = Vec::new();
42    f.take(MAX_PART).read_to_end(&mut out).ok()?;
43    Some(out)
44}
45
46fn find_model_part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>) -> Option<String> {
47    if let Some(rels) = read_part(zip, "_rels/.rels") {
48        let mut target = None;
49        scan(&String::from_utf8_lossy(&rels), &mut |t| {
50            if !t.close && t.name == "Relationship" && target.is_none() && t.attr("Type").is_some_and(|ty| ty.ends_with("/3dmodel")) {
51                target = t.attr("Target").map(|s| s.trim_start_matches('/').to_string());
52            }
53        });
54        if let Some(t) = target
55            && entry_name(zip, &t).is_some()
56        {
57            return Some(t);
58        }
59    }
60    if entry_name(zip, "3D/3dmodel.model").is_some() {
61        return Some("3D/3dmodel.model".into());
62    }
63    (0..zip.len()).find_map(|i| {
64        let f = zip.by_index_raw(i).ok()?;
65        let full = String::from_utf8_lossy(f.name_raw()).into_owned();
66        (!f.is_dir() && full.to_ascii_lowercase().ends_with(".model")).then_some(full)
67    })
68}
69
70// ---- XML ----------------------------------------------------------------
71
72struct Tag {
73    /// Local name (prefix removed).
74    name: String,
75    /// True for a closing tag; a self-closing tag is reported as open then
76    /// close.
77    close: bool,
78    attrs: Vec<(String, String)>,
79}
80
81impl Tag {
82    fn attr(&self, name: &str) -> Option<&str> {
83        self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
84    }
85
86    fn num(&self, name: &str) -> Option<f32> {
87        self.attr(name)?.trim().parse().ok()
88    }
89
90    fn id(&self, name: &str) -> Option<u32> {
91        self.attr(name)?.trim().parse().ok()
92    }
93}
94
95fn local(name: &str) -> &str {
96    name.rsplit(':').next().unwrap_or(name)
97}
98
99fn unescape(s: &str) -> String {
100    if !s.contains('&') {
101        return s.to_string();
102    }
103    s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
104}
105
106/// Calls `f` for every tag of `s`, tolerating comments, declarations,
107/// CDATA, stray text and a truncated end.
108fn scan(s: &str, f: &mut dyn FnMut(Tag)) {
109    let b = s.as_bytes();
110    let mut i = 0;
111    while let Some(off) = s[i..].find('<') {
112        i += off;
113        let rest = &s[i..];
114        if rest.starts_with("<!--") {
115            match rest.find("-->") {
116                Some(e) => i += e + 3,
117                None => return,
118            }
119            continue;
120        }
121        if rest.starts_with("<![CDATA[") {
122            match rest.find("]]>") {
123                Some(e) => i += e + 3,
124                None => return,
125            }
126            continue;
127        }
128        if rest.starts_with("<?") || rest.starts_with("<!") {
129            match rest.find('>') {
130                Some(e) => i += e + 1,
131                None => return,
132            }
133            continue;
134        }
135        i += 1;
136        let close = b.get(i) == Some(&b'/');
137        if close {
138            i += 1;
139        }
140        let start = i;
141        while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'/' && b[i] != b'>' {
142            i += 1;
143        }
144        let name = local(&s[start..i]).to_string();
145        let mut attrs = Vec::new();
146        let mut selfclose = false;
147        loop {
148            while i < b.len() && b[i].is_ascii_whitespace() {
149                i += 1;
150            }
151            match b.get(i) {
152                None => return,
153                Some(b'>') => {
154                    i += 1;
155                    break;
156                }
157                Some(b'/') => {
158                    selfclose = true;
159                    i += 1;
160                }
161                Some(_) => {
162                    let ks = i;
163                    while i < b.len() && !b[i].is_ascii_whitespace() && !matches!(b[i], b'=' | b'>' | b'/') {
164                        i += 1;
165                    }
166                    let key = local(&s[ks..i]).to_string();
167                    while i < b.len() && b[i].is_ascii_whitespace() {
168                        i += 1;
169                    }
170                    if b.get(i) != Some(&b'=') {
171                        if i == ks {
172                            i += 1;
173                        }
174                        continue;
175                    }
176                    i += 1;
177                    while i < b.len() && b[i].is_ascii_whitespace() {
178                        i += 1;
179                    }
180                    let val = match b.get(i) {
181                        Some(&q) if q == b'"' || q == b'\'' => {
182                            let vs = i + 1;
183                            match s[vs..].find(q as char) {
184                                Some(e) => {
185                                    i = vs + e + 1;
186                                    unescape(&s[vs..vs + e])
187                                }
188                                None => return,
189                            }
190                        }
191                        _ => {
192                            let vs = i;
193                            while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'>' {
194                                i += 1;
195                            }
196                            unescape(&s[vs..i])
197                        }
198                    };
199                    if !key.is_empty() {
200                        attrs.push((key, val));
201                    }
202                }
203            }
204        }
205        if close {
206            f(Tag { name, close: true, attrs: Vec::new() });
207        } else {
208            f(Tag { name: name.clone(), close: false, attrs });
209            if selfclose {
210                f(Tag { name, close: true, attrs: Vec::new() });
211            }
212        }
213    }
214}
215
216// ---- Document -------------------------------------------------------------
217
218/// Corner indices and the triangle's property (group id, index), if any.
219type Triangle = ([u32; 3], Option<(u32, usize)>);
220
221#[derive(Default)]
222struct Object {
223    name: String,
224    pid: Option<u32>,
225    pindex: usize,
226    positions: Vec<V3>,
227    /// Corner indices and the triangle's own (pid, p1), when it has one.
228    triangles: Vec<Triangle>,
229    components: Vec<(u32, M4)>,
230}
231
232#[derive(Default)]
233struct Document {
234    objects: HashMap<u32, Object>,
235    order: Vec<u32>,
236    /// Property groups by id: base materials and colour groups.
237    groups: HashMap<u32, Vec<Material>>,
238    build: Vec<(u32, M4)>,
239    has_build: bool,
240}
241
242const IDENTITY: M4 = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
243
244/// A 3MF transform (12 numbers, row-vector convention) as a column-major
245/// matrix; identity when absent or malformed.
246fn parse_transform(s: Option<&str>) -> M4 {
247    let Some(s) = s else { return IDENTITY };
248    let nums: Vec<f32> = s.split_whitespace().filter_map(|t| t.parse().ok()).collect();
249    if nums.len() != 12 || nums.iter().any(|n| !n.is_finite()) {
250        return IDENTITY;
251    }
252    let mut m = IDENTITY;
253    for (c, col) in m.iter_mut().enumerate() {
254        for (r, cell) in col.iter_mut().take(3).enumerate() {
255            *cell = nums[c * 3 + r];
256        }
257    }
258    m
259}
260
261/// `#RRGGBB` or `#RRGGBBAA` as (colour, opacity).
262fn parse_color(s: &str) -> Option<(V3, f32)> {
263    let h = s.trim().strip_prefix('#')?;
264    if !(h.len() == 6 || h.len() == 8) || !h.is_ascii() {
265        return None;
266    }
267    let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok().map(|v| f32::from(v) / 255.0);
268    let a = if h.len() == 8 { byte(6)? } else { 1.0 };
269    Some(([byte(0)?, byte(2)?, byte(4)?], a))
270}
271
272fn color_material(name: Option<&str>, spec: &str) -> Option<Material> {
273    let (color, opacity) = parse_color(spec)?;
274    let name = match name {
275        Some(n) if !n.is_empty() => n.to_string(),
276        _ => format!("#{}", spec.trim().trim_start_matches('#').get(..6).unwrap_or("").to_ascii_uppercase()),
277    };
278    Some(Material { name, color, opacity })
279}
280
281fn parse_document(xml: &str) -> Document {
282    let mut doc = Document::default();
283    let mut cur: Option<(u32, Object)> = None;
284    let mut group: Option<(u32, Vec<Material>)> = None;
285    let mut in_build = false;
286    scan(xml, &mut |t| {
287        if t.close {
288            match t.name.as_str() {
289                "object" => {
290                    if let Some((id, o)) = cur.take()
291                        && doc.objects.insert(id, o).is_none()
292                    {
293                        doc.order.push(id);
294                    }
295                }
296                "basematerials" | "colorgroup" => {
297                    if let Some((id, g)) = group.take() {
298                        doc.groups.insert(id, g);
299                    }
300                }
301                "build" => in_build = false,
302                _ => {}
303            }
304            return;
305        }
306        match t.name.as_str() {
307            "object" => {
308                if let Some(id) = t.id("id") {
309                    cur = Some((
310                        id,
311                        Object {
312                            name: t.attr("name").unwrap_or("").to_string(),
313                            pid: t.id("pid"),
314                            pindex: t.id("pindex").unwrap_or(0) as usize,
315                            ..Object::default()
316                        },
317                    ));
318                }
319            }
320            "vertex" => {
321                if let Some((_, o)) = cur.as_mut() {
322                    let p = [t.num("x").unwrap_or(0.0), t.num("y").unwrap_or(0.0), t.num("z").unwrap_or(0.0)];
323                    o.positions.push(if p.iter().all(|c| c.is_finite()) { p } else { [0.0; 3] });
324                }
325            }
326            "triangle" => {
327                if let Some((_, o)) = cur.as_mut()
328                    && let (Some(a), Some(b), Some(c)) = (t.id("v1"), t.id("v2"), t.id("v3"))
329                {
330                    let p1 = t.id("p1").map(|p| p as usize);
331                    let prop = match t.id("pid") {
332                        Some(pid) => Some((pid, p1.unwrap_or(0))),
333                        None => o.pid.map(|pid| (pid, p1.unwrap_or(o.pindex))),
334                    };
335                    o.triangles.push(([a, b, c], prop));
336                }
337            }
338            "component" => {
339                if let (Some((_, o)), Some(id)) = (cur.as_mut(), t.id("objectid")) {
340                    o.components.push((id, parse_transform(t.attr("transform"))));
341                }
342            }
343            "basematerials" | "colorgroup" => {
344                if let Some(id) = t.id("id") {
345                    group = Some((id, Vec::new()));
346                }
347            }
348            "base" => {
349                if let Some((_, g)) = group.as_mut() {
350                    // An unreadable entry keeps its slot so indices stay right.
351                    g.push(t.attr("displaycolor").and_then(|c| color_material(t.attr("name"), c)).unwrap_or(Material { name: String::new(), color: super::DEFAULT_COLOR, opacity: 1.0 }));
352                }
353            }
354            "color" => {
355                if let Some((_, g)) = group.as_mut() {
356                    g.push(t.attr("color").and_then(|c| color_material(None, c)).unwrap_or(Material { name: String::new(), color: super::DEFAULT_COLOR, opacity: 1.0 }));
357                }
358            }
359            "build" => {
360                in_build = true;
361                doc.has_build = true;
362            }
363            "item" if in_build => {
364                if let Some(id) = t.id("objectid") {
365                    doc.build.push((id, parse_transform(t.attr("transform"))));
366                }
367            }
368            _ => {}
369        }
370    });
371    doc
372}
373
374impl Document {
375    /// The material a property (group id, index) names. Entries that could
376    /// not be read count as no material.
377    fn material(&self, prop: Option<(u32, usize)>) -> Option<&Material> {
378        let (pid, idx) = prop?;
379        let m = self.groups.get(&pid)?.get(idx)?;
380        (m.color != super::DEFAULT_COLOR || !m.name.is_empty()).then_some(m)
381    }
382
383    fn flatten(&self) -> Result<Model> {
384        let mut meshes = Vec::new();
385        let mut instances = 0usize;
386        if self.has_build && !self.build.is_empty() {
387            for &(id, m) in &self.build {
388                self.instantiate(id, &m, 0, &mut Vec::new(), &mut instances, &mut meshes);
389            }
390        } else {
391            let used: std::collections::HashSet<u32> = self.objects.values().flat_map(|o| o.components.iter().map(|c| c.0)).collect();
392            for id in self.order.iter().filter(|id| !used.contains(id)) {
393                self.instantiate(*id, &IDENTITY, 0, &mut Vec::new(), &mut instances, &mut meshes);
394            }
395        }
396        Model { meshes }.finish()
397    }
398
399    fn instantiate(&self, id: u32, m: &M4, depth: usize, stack: &mut Vec<u32>, instances: &mut usize, out: &mut Vec<Mesh>) {
400        if depth > MAX_DEPTH || stack.contains(&id) || *instances >= MAX_INSTANCES {
401            return;
402        }
403        let Some(o) = self.objects.get(&id) else { return };
404        *instances += 1;
405        self.emit_mesh(o, m, out);
406        stack.push(id);
407        for (child, cm) in &o.components {
408            self.instantiate(*child, &math::mul(m, cm), depth + 1, stack, instances, out);
409        }
410        stack.pop();
411    }
412
413    /// One mesh per distinct colour among the triangles of `o`.
414    fn emit_mesh(&self, o: &Object, m: &M4, out: &mut Vec<Mesh>) {
415        if o.triangles.is_empty() {
416            return;
417        }
418        struct Bucket {
419            material: Option<Material>,
420            remap: HashMap<u32, u32>,
421            mesh: Mesh,
422        }
423        let mut buckets: Vec<Bucket> = Vec::new();
424        let mut by_key: HashMap<Option<(String, [u32; 3], u32)>, usize> = HashMap::new();
425        for (tri, prop) in &o.triangles {
426            if tri.iter().any(|&v| v as usize >= o.positions.len()) {
427                continue;
428            }
429            let mat = self.material(*prop);
430            let key = mat.map(|x| (x.name.clone(), x.color.map(f32::to_bits), x.opacity.to_bits()));
431            let bi = *by_key.entry(key).or_insert_with(|| {
432                buckets.push(Bucket {
433                    material: mat.cloned(),
434                    remap: HashMap::new(),
435                    mesh: Mesh { name: o.name.clone(), ..Mesh::default() },
436                });
437                buckets.len() - 1
438            });
439            let b = &mut buckets[bi];
440            for &v in tri {
441                let next = b.mesh.positions.len() as u32;
442                let ni = *b.remap.entry(v).or_insert_with(|| {
443                    b.mesh.positions.push(math::transform_point(m, o.positions[v as usize]));
444                    next
445                });
446                b.mesh.indices.push(ni);
447            }
448        }
449        out.extend(buckets.into_iter().map(|mut b| {
450            b.mesh.material = b.material;
451            b.mesh
452        }));
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::io::Write;
460    use zip::write::SimpleFileOptions;
461
462    fn make_zip(files: &[(&str, &str)]) -> Vec<u8> {
463        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
464        for (name, body) in files {
465            w.start_file(*name, SimpleFileOptions::default()).unwrap();
466            w.write_all(body.as_bytes()).unwrap();
467        }
468        w.finish().unwrap().into_inner()
469    }
470
471    fn model_zip(xml: &str) -> Vec<u8> {
472        make_zip(&[("3D/3dmodel.model", xml)])
473    }
474
475    const TRI: &str = r#"<vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/></vertices>"#;
476
477    fn doc(resources: &str, build: &str) -> String {
478        format!(r#"<?xml version="1.0"?><model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>{resources}</resources><build>{build}</build></model>"#)
479    }
480
481    #[test]
482    fn single_triangle() {
483        let xml = doc(&format!(r#"<object id="1" name="tri"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>"#), r#"<item objectid="1"/>"#);
484        let m = parse(&model_zip(&xml)).unwrap();
485        assert_eq!(m.meshes.len(), 1);
486        assert_eq!(m.meshes[0].name, "tri");
487        assert_eq!(m.meshes[0].indices, vec![0, 1, 2]);
488        assert_eq!(m.meshes[0].positions[1], [1.0, 0.0, 0.0]);
489        assert!(m.meshes[0].material.is_none());
490    }
491
492    #[test]
493    fn item_transform_translates() {
494        let xml = doc(&format!(r#"<object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>"#), r#"<item objectid="1" transform="1 0 0 0 1 0 0 0 1 10 20 30"/>"#);
495        let m = parse(&model_zip(&xml)).unwrap();
496        assert_eq!(m.meshes[0].positions[0], [10.0, 20.0, 30.0]);
497        assert_eq!(m.meshes[0].positions[1], [11.0, 20.0, 30.0]);
498    }
499
500    #[test]
501    fn object_base_material() {
502        let xml = doc(
503            &format!(r##"<basematerials id="5"><base name="Red" displaycolor="#FF0000"/><base name="Blue" displaycolor="#0000FF80"/></basematerials><object id="1" pid="5" pindex="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>"##),
504            r#"<item objectid="1"/>"#,
505        );
506        let m = parse(&model_zip(&xml)).unwrap();
507        let mat = m.meshes[0].material.clone().unwrap();
508        assert_eq!(mat.name, "Blue");
509        assert_eq!(mat.color, [0.0, 0.0, 1.0]);
510        assert!((mat.opacity - 128.0 / 255.0).abs() < 1e-6);
511    }
512
513    #[test]
514    fn per_triangle_colors_split() {
515        let xml = doc(
516            &format!(
517                r##"<colorgroup id="2"><color color="#00FF00"/><color color="#112233"/></colorgroup><object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2" pid="2" p1="0"/><triangle v1="2" v2="1" v3="0" pid="2" p1="1"/><triangle v1="0" v2="2" v3="1"/></triangles></mesh></object>"##
518            ),
519            r#"<item objectid="1"/>"#,
520        );
521        let m = parse(&model_zip(&xml)).unwrap();
522        assert_eq!(m.meshes.len(), 3);
523        assert_eq!(m.meshes[0].material.as_ref().unwrap().name, "#00FF00");
524        assert_eq!(m.meshes[1].material.as_ref().unwrap().color, [17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0]);
525        assert!(m.meshes[2].material.is_none());
526    }
527
528    #[test]
529    fn components_twice() {
530        let xml = doc(
531            &format!(
532                r#"<object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object><object id="2"><components><component objectid="1"/><component objectid="1" transform="1 0 0 0 1 0 0 0 1 5 0 0"/></components></object>"#
533            ),
534            r#"<item objectid="2" transform="1 0 0 0 1 0 0 0 1 0 0 100"/>"#,
535        );
536        let m = parse(&model_zip(&xml)).unwrap();
537        assert_eq!(m.meshes.len(), 2);
538        assert_eq!(m.meshes[0].positions[0], [0.0, 0.0, 100.0]);
539        assert_eq!(m.meshes[1].positions[0], [5.0, 0.0, 100.0]);
540    }
541
542    #[test]
543    fn no_build_uses_unreferenced_objects_and_survives_cycles() {
544        let xml = doc(
545            &format!(r#"<object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh><components><component objectid="2"/></components></object><object id="2"><components><component objectid="1"/></components></object>"#),
546            "",
547        );
548        // Both are used as components of each other, so nothing is a root.
549        assert!(parse(&model_zip(&xml)).is_err());
550        let xml = doc(&format!(r#"<object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>"#), "");
551        assert_eq!(parse(&model_zip(&xml)).unwrap().meshes.len(), 1);
552    }
553
554    #[test]
555    fn model_found_through_rels() {
556        let xml = doc(&format!(r#"<object id="1"><mesh>{TRI}<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>"#), r#"<item objectid="1"/>"#);
557        let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/Other/part.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>"#;
558        let z = make_zip(&[("_rels/.rels", rels), ("Other/part.model", &xml), ("3D/3dmodel.model", "<model/>")]);
559        assert_eq!(parse(&z).unwrap().triangle_count(), 1);
560    }
561
562    #[test]
563    fn namespaced_document() {
564        let xml = format!(
565            r#"<?xml version='1.0'?><!-- c --><m:model xmlns:m='urn:x'><m:resources><m:object id='1' name='a &amp; b'><m:mesh>{}<m:triangles><m:triangle v1='0' v2='1' v3='2'/></m:triangles></m:mesh></m:object></m:resources><m:build><m:item objectid='1'/></m:build></m:model>"#,
566            TRI.replace("<vertex", "<m:vertex").replace("</vertices", "</m:vertices").replace("<vertices", "<m:vertices")
567        );
568        let m = parse(&model_zip(&xml)).unwrap();
569        assert_eq!(m.meshes[0].name, "a & b");
570        assert_eq!(m.triangle_count(), 1);
571    }
572
573    #[test]
574    fn not_a_zip() {
575        assert!(parse(b"hello, this is not a zip").is_err());
576    }
577
578    #[test]
579    fn missing_model() {
580        let z = make_zip(&[("readme.txt", "hi")]);
581        let e = parse(&z).unwrap_err();
582        assert!(e.0.contains("no 3D model"), "{e}");
583    }
584}