1use 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#[derive(Debug, Clone)]
30pub struct Object<'a> {
31 pub mesh: &'a Triangulation,
33 pub name: Option<String>,
35}
36
37#[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
108fn escaped(text: &str) -> String {
110 text.replace('&', "&")
111 .replace('<', "<")
112 .replace('>', ">")
113 .replace('"', """)
114 .replace('\'', "'")
115}
116
117fn 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 out.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
137 out.extend_from_slice(&20_u16.to_le_bytes()); out.extend_from_slice(&0_u16.to_le_bytes()); out.extend_from_slice(&0_u16.to_le_bytes()); out.extend_from_slice(&0_u16.to_le_bytes()); out.extend_from_slice(&0_u16.to_le_bytes()); 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()); out.extend_from_slice(name_bytes);
148 out.extend_from_slice(data);
149
150 directory.extend_from_slice(&0x0201_4b50_u32.to_le_bytes());
152 directory.extend_from_slice(&20_u16.to_le_bytes()); directory.extend_from_slice(&20_u16.to_le_bytes()); 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()); directory.extend_from_slice(&0_u16.to_le_bytes()); directory.extend_from_slice(&0_u16.to_le_bytes()); directory.extend_from_slice(&0_u16.to_le_bytes()); directory.extend_from_slice(&0_u32.to_le_bytes()); 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 out.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
177 out.extend_from_slice(&0_u16.to_le_bytes()); out.extend_from_slice(&0_u16.to_le_bytes()); 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()); out
185}
186
187fn 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
216struct 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
243fn 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 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 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 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 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
365fn 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
382fn 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
418pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum ObjectType {
443 Model,
445 SolidSupport,
447 Support,
449 Surface,
451 Other,
453}
454
455#[derive(Debug, Clone)]
457pub struct ThreeMfObject {
458 pub name: Option<String>,
460 pub object_type: ObjectType,
463 pub mesh: Triangulation,
466 pub colour: Option<[f64; 4]>,
469}
470
471#[derive(Debug, Clone)]
473pub struct ThreeMfImport {
474 pub objects: Vec<ThreeMfObject>,
476 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";
486const UNDERSTOOD: [&str; 2] = [PRODUCTION, MATERIAL];
490
491pub 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
599fn 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
622fn 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#[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 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
691struct 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 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
717struct Part {
719 scale: f64,
720 objects: HashMap<u32, ObjectDef>,
721 groups: HashMap<u32, Option<Vec<[f64; 4]>>>,
724 build: Vec<Reference>,
725}
726
727impl Part {
728 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 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 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 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
938fn 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
964fn 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
988fn 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#[derive(Default)]
997struct Flat {
998 positions: Vec<Point>,
999 triangles: Vec<[u32; 3]>,
1000 colours: Vec<Option<[f64; 4]>>,
1003 skipped_properties: bool,
1004 degenerate: usize,
1005}
1006
1007const 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 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 #[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}