//! 3MF (3D Manufacturing Format, Core specification 1.3) packages: a zip with //! `[Content_Types].xml`, `_rels/.rels` and the model part `3D/3dmodel.model`. //! //! Writing: one mesh object per body (shared vertices, outward winding), named after the body, //! one build item each, colours as base materials. Reading: every build item becomes one mesh //! (components flattened with their transforms), converted to millimetres. Packages are //! untrusted: sizes, entry counts, mesh sizes and component nesting are capped. use std::collections::HashMap; use std::fmt::Write as _; use std::io::{Cursor, Read, Write}; use quick_xml::Reader; use quick_xml::events::{BytesStart, Event}; use solvecraft_geom::Vec3; use crate::{IoError, Result}; /// Largest 3MF file we read. pub const MAX_3MF_BYTES: usize = 512 << 20; /// Largest uncompressed part we read (zip bombs stop here). const MAX_PART: u64 = 1 << 30; const MAX_ENTRIES: usize = 10_000; /// Most vertices or triangles in one file. const MAX_ELEMENTS: usize = 20_000_000; const MAX_COMPONENT_DEPTH: usize = 16; const MAX_XML_DEPTH: usize = 256; const CORE_NS: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"; const MODEL_REL: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"; /// A named triangle mesh (millimetres, counter-clockwise seen from outside). #[derive(Clone, Debug, Default, PartialEq)] pub struct MeshObject { pub name: String, pub positions: Vec, pub triangles: Vec<[u32; 3]>, pub color: Option<[f32; 3]>, /// 0 = opaque, 1 = invisible (written as the colour's alpha). pub transparency: f32, /// Colours of their own for some triangles (faces with appearances): colour 0..1 and /// transparency; `tri_looks` gives each triangle's entry + 1 (0: the object's own), and is /// empty when no triangle has one. pub looks: Vec<([f32; 3], f32)>, pub tri_looks: Vec, } fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => o.push_str("&"), '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), '\'' => o.push_str("'"), c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {} c => o.push(c), } } o } /// Shared vertices (merged at single precision, as written) and non-degenerate triangles. fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) { let mut map: HashMap<[u32; 3], u32> = HashMap::new(); let mut verts: Vec<[f32; 3]> = Vec::new(); let mut remap = Vec::with_capacity(m.positions.len()); for p in &m.positions { let v = [p.x as f32 + 0.0, p.y as f32 + 0.0, p.z as f32 + 0.0]; let key = v.map(f32::to_bits); let next = u32::try_from(verts.len()).unwrap_or(u32::MAX); let i = *map.entry(key).or_insert_with(|| { verts.push(v); next }); remap.push(i); } let tris = m .triangles .iter() .enumerate() .filter_map(|(k, t)| { let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?); (a != b && b != c && c != a).then_some(([a, b, c], m.tri_looks.get(k).copied().unwrap_or(0))) }) .collect(); (verts, tris) } /// Make a tessellation watertight: merge vertices closer than `eps` and split triangles at /// vertices lying on their open sides (T-junctions between separately meshed faces). pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec, Vec<[u32; 3]>) { let (v, t, _) = weld_tagged(positions, triangles, &[], eps); (v, t) } /// [`weld`] keeping a tag per triangle (pieces of a split triangle keep its tag; missing tags /// are 0). pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps: f64) -> (Vec, Vec<[u32; 3]>, Vec) { // Merge on a grid of cell `eps` (a vertex joins one already in its own or a neighbour cell). let cell = |p: Vec3| [(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64]; let mut grid: HashMap<[i64; 3], Vec> = HashMap::new(); let mut verts: Vec = Vec::new(); let mut remap = Vec::with_capacity(positions.len()); for p in positions { let c = cell(*p); let mut found = None; 'search: for dx in -1..=1 { for dy in -1..=1 { for dz in -1..=1 { for &i in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() { if verts.get(i as usize).is_some_and(|q| q.dist(*p) <= eps) { found = Some(i); break 'search; } } } } } let i = match found { Some(i) => i, None => { let i = u32::try_from(verts.len()).unwrap_or(u32::MAX); verts.push(*p); grid.entry(c).or_default().push(i); i } }; remap.push(i); } let (mut tris, mut tags): (Vec<[u32; 3]>, Vec) = triangles .iter() .enumerate() .filter_map(|(k, t)| { let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?); (a != b && b != c && c != a).then_some(([a, b, c], tags.get(k).copied().unwrap_or(0))) }) .unzip(); // T-junctions: split open sides at open-side vertices lying on them. for _ in 0..8 { let mut half: HashMap<(u32, u32), usize> = HashMap::new(); for t in &tris { for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { *half.entry((a, b)).or_default() += 1; } } let open: Vec<(u32, u32)> = half.keys().filter(|(a, b)| !half.contains_key(&(*b, *a))).copied().collect(); if open.is_empty() || open.len() > 200_000 { break; } let mut cand: Vec = open.iter().flat_map(|(a, b)| [*a, *b]).collect(); cand.sort_unstable(); cand.dedup(); // Vertices on each open side, ordered from its start. let mut splits: HashMap<(u32, u32), Vec<(f64, u32)>> = HashMap::new(); for &(a, b) in &open { let (Some(pa), Some(pb)) = (verts.get(a as usize), verts.get(b as usize)) else { continue }; let d = *pb - *pa; let l2 = d.dot(d); if !(l2 > 0.0) { continue; } let mut on: Vec<(f64, u32)> = cand .iter() .filter(|v| **v != a && **v != b) .filter_map(|v| { let p = *verts.get(*v as usize)?; let t = (p - *pa).dot(d) / l2; (t > 1e-9 && t < 1.0 - 1e-9 && (*pa + d * t).dist(p) <= eps * 4.0).then_some((t, *v)) }) .collect(); if !on.is_empty() { on.sort_by(|x, y| x.0.total_cmp(&y.0)); splits.insert((a, b), on); } } if splits.is_empty() { break; } let mut next = Vec::with_capacity(tris.len() + splits.len() * 2); let mut next_tags = Vec::with_capacity(next.capacity()); for (t, tag) in tris.iter().zip(&tags) { // Rotate so the split side (if any) is (t0, t1); split one side per pass. let rot = [[t[0], t[1], t[2]], [t[1], t[2], t[0]], [t[2], t[0], t[1]]]; match rot.iter().find_map(|r| splits.get(&(r[0], r[1])).map(|s| (r, s))) { Some((r, s)) => { let mut prev = r[0]; for (_, v) in s { next.push([prev, *v, r[2]]); next_tags.push(*tag); prev = *v; } next.push([prev, r[1], r[2]]); next_tags.push(*tag); } None => { next.push(*t); next_tags.push(*tag); } } } tris = next; tags = next_tags; } (verts, tris, tags) } /// The model part's XML. pub fn model_xml(objects: &[MeshObject]) -> String { let mut x = String::new(); x.push_str("\n"); let _ = writeln!(x, ""); x.push_str(" SolidCraft\n \n"); let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty()); // Base material group id 1 (each object's own colour, then its faces' colours); objects // from 2. A see-through colour carries its alpha (#RRGGBBAA). let display = |c: [f32; 3], transparency: f32| { let c = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); let alpha = if transparency > 0.0 { format!("{:02X}", ((1.0 - transparency.clamp(0.0, 1.0)) * 255.0).round() as u8) } else { String::new() }; format!("#{:02X}{:02X}{:02X}{alpha}", c[0], c[1], c[2]) }; let mut first = Vec::with_capacity(objects.len()); if colored { x.push_str(" \n"); let mut n = 0usize; for o in objects { first.push(n); let _ = writeln!(x, " ", esc(&o.name), display(o.color.unwrap_or([0.7, 0.7, 0.7]), o.transparency)); n += 1; for (i, (c, t)) in o.looks.iter().enumerate() { let _ = writeln!(x, " ", esc(&o.name), i + 1, display(*c, *t)); n += 1; } } x.push_str(" \n"); } for (k, o) in objects.iter().enumerate() { let id = k + 2; let own = first.get(k).copied().unwrap_or(0); let mat = if colored { format!(" pid=\"1\" pindex=\"{own}\"") } else { String::new() }; let _ = writeln!(x, " ", esc(&o.name)); x.push_str(" \n \n"); let (verts, tris) = indexed(o); for v in &verts { let _ = writeln!(x, " ", v[0], v[1], v[2]); } x.push_str(" \n \n"); for (t, look) in &tris { match look { l if colored && *l > 0 && (*l as usize) <= o.looks.len() => { let _ = writeln!(x, " ", t[0], t[1], t[2], own + *l as usize); } _ => { let _ = writeln!(x, " ", t[0], t[1], t[2]); } } } x.push_str(" \n \n \n"); } x.push_str(" \n \n"); for k in 0..objects.len() { let _ = writeln!(x, " ", k + 2); } x.push_str(" \n\n"); x } /// A 3MF package of meshes. pub fn write_3mf(objects: &[MeshObject]) -> Result> { if objects.is_empty() { return Err(IoError::Empty); } let content_types = "\n\n \n \n\n"; let rels = format!( "\n\n \n\n" ); let model = model_xml(objects); let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, data) in [("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", rels.as_bytes()), ("3D/3dmodel.model", model.as_bytes())] { let opts = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .last_modified_time(zip::DateTime::default()) .large_file(data.len() as u64 >= u32::MAX as u64); zip.start_file(name, opts).map_err(|e| IoError::Invalid(format!("3MF: {e}")))?; zip.write_all(data).map_err(|e| IoError::Invalid(format!("3MF: {e}")))?; } zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Invalid(format!("3MF: {e}"))) } // ---------------------------------------------------------------- reading fn bad(msg: impl Into) -> Result { Err(IoError::Invalid(format!("3MF: {}", msg.into()))) } fn read_part(zip: &mut zip::ZipArchive>, name: &str) -> Result>> { let want = name.trim_start_matches('/').to_ascii_lowercase(); let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want)) else { return Ok(None); }; let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?; if f.size() > MAX_PART { return bad(format!("{name} expands to {} bytes", f.size())); } let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize); (&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?; if out.len() as u64 > MAX_PART { return bad(format!("{name} expands to more than 1 GB")); } Ok(Some(out)) } fn attrs(e: &BytesStart) -> HashMap { let mut m = HashMap::new(); for a in e.attributes().flatten() { let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string(); let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); m.insert(k, v); } m } /// Row-vector affine transform `[m00 m01 m02 m10 … m32]` (3MF order). type Xf = [f64; 12]; const IDENTITY: Xf = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]; fn parse_xf(s: Option<&String>) -> Result { let Some(s) = s else { return Ok(IDENTITY) }; let v: Vec = s .split_whitespace() .map(|t| t.parse::().ok().filter(|x| x.is_finite() && x.abs() < 1e12)) .collect::>() .ok_or_else(|| IoError::Invalid(format!("3MF: bad transform `{s}`")))?; let mut m = IDENTITY; if v.len() != 12 { return bad(format!("transform with {} numbers", v.len())); } m.copy_from_slice(&v); Ok(m) } fn apply(m: &Xf, p: Vec3) -> Vec3 { Vec3::new(p.x * m[0] + p.y * m[3] + p.z * m[6] + m[9], p.x * m[1] + p.y * m[4] + p.z * m[7] + m[10], p.x * m[2] + p.y * m[5] + p.z * m[8] + m[11]) } /// `a` then `b`. fn compose(a: &Xf, b: &Xf) -> Xf { let r = |i: usize| Vec3::new(a[i * 3], a[i * 3 + 1], a[i * 3 + 2]); let lin = |v: Vec3| Vec3::new(v.x * b[0] + v.y * b[3] + v.z * b[6], v.x * b[1] + v.y * b[4] + v.z * b[7], v.x * b[2] + v.y * b[5] + v.z * b[8]); let (x, y, z, t) = (lin(r(0)), lin(r(1)), lin(r(2)), apply(b, r(3))); [x.x, x.y, x.z, y.x, y.y, y.z, z.x, z.y, z.z, t.x, t.y, t.z] } fn det(m: &Xf) -> f64 { m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]) } #[derive(Default)] struct Object { name: String, positions: Vec, triangles: Vec<[u32; 3]>, components: Vec<(u32, Xf)>, material: Option<(u32, usize)>, } #[derive(Default)] struct Model { unit: f64, objects: HashMap, materials: HashMap>>, build: Vec<(u32, Xf)>, warnings: Vec, } fn color(s: &str) -> Option<[f32; 3]> { let h = s.trim().strip_prefix('#')?; if h.len() != 6 && h.len() != 8 { return None; } let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v as f32 / 255.0); Some([c(0)?, c(2)?, c(4)?]) } fn unit_mm(u: &str) -> Option { Some(match u { "micron" => 1e-3, "millimeter" => 1.0, "centimeter" => 10.0, "inch" => 25.4, "foot" => 304.8, "meter" => 1000.0, _ => return None, }) } fn parse_model(xml: &[u8]) -> Result { let mut rd = Reader::from_reader(xml); rd.config_mut().trim_text(true); let mut buf = Vec::new(); let mut m = Model { unit: 1.0, ..Default::default() }; let mut depth = 0usize; let mut cur: Option<(u32, Object)> = None; let mut cur_mat: Option = None; let mut elements = 0usize; let mut seen_model = false; loop { let ev = rd.read_event_into(&mut buf).map_err(|e| IoError::Invalid(format!("3MF model XML: {e}")))?; let (e, empty) = match &ev { Event::Start(e) => (Some(e.clone()), false), Event::Empty(e) => (Some(e.clone()), true), Event::End(end) => { depth = depth.saturating_sub(1); match end.local_name().as_ref() { b"object" => { if let Some((id, o)) = cur.take() { m.objects.insert(id, o); } } b"basematerials" => cur_mat = None, _ => {} } (None, false) } Event::Eof => break, _ => (None, false), }; let Some(e) = e else { buf.clear(); continue; }; if !empty { depth += 1; if depth > MAX_XML_DEPTH { return bad("XML nested too deeply"); } } elements += 1; if elements > MAX_ELEMENTS * 2 { return bad("model too large"); } let a = attrs(&e); let num = |k: &str| a.get(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let id = |k: &str| a.get(k).and_then(|v| v.trim().parse::().ok()); match e.local_name().as_ref() { b"model" => { seen_model = true; if let Some(u) = a.get("unit") { m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?; } } b"basematerials" => { let gid = id("id").ok_or_else(|| IoError::Invalid("3MF: base materials without id".into()))?; m.materials.insert(gid, Vec::new()); cur_mat = if empty { None } else { Some(gid) }; } b"base" => { if let Some(g) = cur_mat.and_then(|g| m.materials.get_mut(&g)) { g.push(a.get("displaycolor").and_then(|c| color(c))); } } b"object" => { let oid = id("id").ok_or_else(|| IoError::Invalid("3MF: object without id".into()))?; let o = Object { name: a.get("name").cloned().unwrap_or_default(), material: id("pid").map(|p| (p, a.get("pindex").and_then(|v| v.trim().parse::().ok()).unwrap_or(0))), ..Default::default() }; if empty { m.objects.insert(oid, o); } else { cur = Some((oid, o)); } } b"vertex" => { let Some((_, o)) = cur.as_mut() else { return bad("vertex outside an object") }; if o.positions.len() >= MAX_ELEMENTS { return bad("too many vertices"); } let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") }; o.positions.push(Vec3::new(x, y, z)); } b"triangle" => { let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") }; if o.triangles.len() >= MAX_ELEMENTS { return bad("too many triangles"); } let (Some(a1), Some(a2), Some(a3)) = (id("v1"), id("v2"), id("v3")) else { return bad("triangle without v1, v2, v3") }; o.triangles.push([a1, a2, a3]); } b"component" => { let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") }; if a.keys().any(|k| k == "path") { m.warnings.push("components in other model parts (production extension) are not read".into()); buf.clear(); continue; } let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?; o.components.push((target, parse_xf(a.get("transform"))?)); } b"item" => { if a.keys().any(|k| k == "path") { m.warnings.push("build items in other model parts (production extension) are not read".into()); buf.clear(); continue; } let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?; m.build.push((target, parse_xf(a.get("transform"))?)); } _ => {} } buf.clear(); } if !seen_model { return bad("no element"); } Ok(m) } /// Append object `id` (and its components) placed by `xf` to `out`. fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec, total: &mut usize) -> Result<()> { if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&id) { return bad("components nested too deeply or recursive"); } let o = m.objects.get(&id).ok_or_else(|| IoError::Invalid(format!("3MF: missing object {id}")))?; *total = total.saturating_add(o.triangles.len()); if *total > MAX_ELEMENTS { return bad("too many triangles after placing components"); } let n = o.positions.len(); // One object can be placed by thousands of build items and components, and each placement // needs its own transformed copy of the vertices. Copying only the vertices the triangles // reach keeps that cost tied to the triangle budget above; copying the whole vertex array // let an object with many vertices and one triangle multiply into tens of gigabytes. let mut used: Vec = o.triangles.iter().flatten().copied().collect(); used.sort_unstable(); used.dedup(); if used.last().is_some_and(|v| *v as usize >= n) { return bad(format!("object {id}: triangle refers to a missing vertex")); } let base = u32::try_from(out.positions.len()).map_err(|_| IoError::Invalid("3MF: too many vertices".into()))?; if base as usize + used.len() > u32::MAX as usize { return bad("too many vertices"); } for v in &used { let Some(p) = o.positions.get(*v as usize) else { return bad(format!("object {id}: triangle refers to a missing vertex")); }; out.positions.push(apply(xf, *p) * m.unit); } // Vertices keep their relative order, so a mesh whose triangles use all of them is // numbered exactly as it was in the file. let at = |v: u32| used.binary_search(&v).ok().map(|i| base + i as u32); let flip = det(xf) < 0.0; for t in &o.triangles { let mut q = [0u32; 3]; for (slot, v) in q.iter_mut().zip(t) { let Some(i) = at(*v) else { return bad(format!("object {id}: triangle refers to a missing vertex")); }; *slot = i; } out.triangles.push(if flip { [q[0], q[2], q[1]] } else { q }); } if out.color.is_none() { out.color = o.material.and_then(|(g, i)| m.materials.get(&g).and_then(|g| g.get(i).copied().flatten())); } stack.push(id); for (c, cx) in &o.components { flatten(m, *c, &compose(cx, xf), out, stack, total)?; } stack.pop(); Ok(()) } /// Meshes of a 3MF package (one per build item, in millimetres) and warnings. pub fn read_3mf(bytes: &[u8]) -> Result<(Vec, Vec)> { if bytes.len() > MAX_3MF_BYTES { return bad(format!("file too large ({} MB)", bytes.len() >> 20)); } let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Invalid(format!("3MF is not a zip package: {e}")))?; if zip.len() > MAX_ENTRIES { return bad(format!("{} zip entries", zip.len())); } // The model part named by the package relationships, else the usual name. let mut root = "3D/3dmodel.model".to_string(); if let Some(rels) = read_part(&mut zip, "_rels/.rels")? { let mut rd = Reader::from_reader(rels.as_slice()); let mut buf = Vec::new(); loop { match rd.read_event_into(&mut buf) { Ok(Event::Start(e)) | Ok(Event::Empty(e)) if e.local_name().as_ref() == b"Relationship" => { let a = attrs(&e); if a.get("Type").is_some_and(|t| t.ends_with("/3dmodel")) && let Some(t) = a.get("Target") { root = t.trim_start_matches('/').to_string(); break; } } Ok(Event::Eof) | Err(_) => break, _ => {} } buf.clear(); } } let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?; let m = parse_model(&xml)?; let mut out = Vec::new(); let mut warnings = m.warnings.clone(); let mut total = 0usize; for (k, (id, xf)) in m.build.iter().enumerate() { let mut mo = MeshObject::default(); match flatten(&m, *id, xf, &mut mo, &mut Vec::new(), &mut total) { Ok(()) if !mo.triangles.is_empty() => { let name = m.objects.get(id).map(|o| o.name.trim().to_string()).filter(|n| !n.is_empty()); mo.name = name.unwrap_or_else(|| format!("Mesh{}", k + 1)); out.push(mo); } Ok(()) => warnings.push(format!("build item {} has no triangles", k + 1)), Err(e) => warnings.push(format!("build item {}: {e}", k + 1)), } } if out.is_empty() { return bad(warnings.first().cloned().unwrap_or_else(|| "no meshes to build".into())); } Ok((out, warnings)) } #[cfg(test)] mod tests { use super::*; fn tetra(name: &str) -> MeshObject { MeshObject { name: name.into(), positions: vec![Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)], triangles: vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]], color: None, ..Default::default() } } /// A see-through object writes its alpha; triangles of faces with colours of their own /// point at their entries. #[test] fn face_colours_and_alpha() { let mut t = tetra("T"); t.color = Some([0.125, 0.25, 0.75]); t.transparency = 0.5; t.looks = vec![([1.0, 0.0, 0.0], 0.0)]; t.tri_looks = vec![0, 1, 0, 0]; let model = model_xml(&[t]); assert!(model.contains("displaycolor=\"#2040BF80\""), "{model}"); assert!(model.contains("displaycolor=\"#FF0000\""), "{model}"); assert_eq!(model.matches("p1=\"1\"").count(), 1, "{model}"); } fn package(model: &str, rels: Option<&str>) -> Vec { let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); if let Some(r) = rels { zip.start_file("_rels/.rels", o).unwrap(); zip.write_all(r.as_bytes()).unwrap(); } zip.start_file("3D/3dmodel.model", o).unwrap(); zip.write_all(model.as_bytes()).unwrap(); zip.finish().unwrap().into_inner() } #[test] fn package_is_well_formed() { let mut t = tetra("A <&> \"b\""); t.color = Some([1.0, 0.5, 0.0]); let bytes = write_3mf(&[t, tetra("B")]).unwrap(); let mut zip = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); for part in ["[Content_Types].xml", "_rels/.rels", "3D/3dmodel.model"] { let data = read_part(&mut zip, part).unwrap().unwrap(); // Every part parses as XML to the end. let mut rd = Reader::from_reader(data.as_slice()); let mut buf = Vec::new(); let mut depth = 0i32; loop { match rd.read_event_into(&mut buf).unwrap() { Event::Start(_) => depth += 1, Event::End(_) => depth -= 1, Event::Eof => break, _ => {} } buf.clear(); } assert_eq!(depth, 0, "{part}"); } let model = String::from_utf8(read_part(&mut zip, "3D/3dmodel.model").unwrap().unwrap()).unwrap(); assert!(model.contains("unit=\"millimeter\"") && model.contains(CORE_NS)); assert!(model.contains("displaycolor=\"#FF8000\"") && model.contains("pid=\"1\" pindex=\"0\"")); assert!(model.contains("name=\"A <&> "b"\"")); assert_eq!(model.matches(" \"b\""); assert_eq!(objs[0].color, Some([1.0, 128.0 / 255.0, 0.0])); assert_eq!(objs[0].triangles, tetra("").triangles); } #[test] fn components_transforms_and_units() { let model = r#" "#; let (objs, _) = read_3mf(&package(model, None)).unwrap(); assert_eq!(objs.len(), 1); let o = &objs[0]; assert_eq!(o.name, "Pair"); assert_eq!(o.triangles.len(), 8); // Mirrored by the item, in mm: x in [-60, 0], z in [20, 30]; winding flipped back. let xs: Vec = o.positions.iter().map(|p| p.x).collect(); assert!(xs.iter().any(|x| (*x + 60.0).abs() < 1e-9) && xs.iter().all(|x| *x <= 1e-9)); assert!(o.positions.iter().all(|p| p.z >= 20.0 - 1e-9 && p.z <= 30.0 + 1e-9)); let v6: f64 = o.triangles.iter().map(|t| o.positions[t[0] as usize].dot(o.positions[t[1] as usize].cross(o.positions[t[2] as usize]))).sum(); assert!((v6 / 6.0 - 2.0 * 1000.0 / 6.0).abs() < 1e-6, "{}", v6 / 6.0); } #[test] fn hostile_packages_are_errors() { let head = r#""#; let cases = [ format!( "{head}" ), format!( "{head}" ), format!("{head}"), format!("{head}"), format!("{head}"), r#""#.to_string(), "".repeat(1000), "".repeat(1000)), "not xml at all".to_string(), ]; for c in &cases { assert!(read_3mf(&package(c, None)).is_err(), "{c}"); } assert!(read_3mf(b"").is_err()); assert!(read_3mf(b"PK\x03\x04garbage").is_err()); assert!( read_3mf(&package("", Some(""))) .is_err() ); } /// Every placement needs its own transformed copy of the vertices, so an object with many /// vertices and one triangle used to cost its whole vertex array per build item: 100k /// vertices times 10,000 items asked for about 140 GB and the process aborted on the /// allocation, which no panic guard can intercept. #[test] fn many_placements_of_a_big_object_stay_bounded() { let t = std::time::Instant::now(); let verts: String = (0..100_000).map(|i| format!("", i % 100, i / 100)).collect(); let model = format!( "{}{verts}\ {}", r#""#, "".repeat(10_000) ); let (objs, _) = read_3mf(&package(&model, None)).unwrap(); assert_eq!(objs.len(), 10_000); // Only the three vertices the one triangle reaches are copied per placement. assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len())); assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed()); } /// Triangles are budgeted across every placement, so fan-out cannot outrun the cap. #[test] fn placements_share_the_triangle_budget() { let verts: String = (0..30_000).map(|i| format!("", i % 100)).collect(); let tris: String = (0..10_000).map(|i| format!("", 3 * i, 3 * i + 1, 3 * i + 2)).collect(); let model = format!( "{}{verts}{tris}\ {}", r#""#, "".repeat(4_000) ); let (objs, warnings) = read_3mf(&package(&model, None)).unwrap(); assert!(objs.len() < 4_000, "the shared budget must stop the later items: {} built", objs.len()); assert!(warnings.iter().any(|w| w.contains("too many triangles")), "{warnings:?}"); } }