799 lines
35 KiB
Rust
799 lines
35 KiB
Rust
//! 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<Vec3>,
|
|
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<u32>,
|
|
}
|
|
|
|
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<Vec3>, 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<Vec3>, Vec<[u32; 3]>, Vec<u32>) {
|
|
// 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<u32>> = HashMap::new();
|
|
let mut verts: Vec<Vec3> = 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<u32>) = 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<u32> = 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\">");
|
|
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n <resources>\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(" <basematerials id=\"1\">\n");
|
|
let mut n = 0usize;
|
|
for o in objects {
|
|
first.push(n);
|
|
let _ =
|
|
writeln!(x, " <base name=\"{}\" displaycolor=\"{}\"/>", 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, " <base name=\"{} face {}\" displaycolor=\"{}\"/>", esc(&o.name), i + 1, display(*c, *t));
|
|
n += 1;
|
|
}
|
|
}
|
|
x.push_str(" </basematerials>\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, " <object id=\"{id}\" type=\"model\" name=\"{}\"{mat}>", esc(&o.name));
|
|
x.push_str(" <mesh>\n <vertices>\n");
|
|
let (verts, tris) = indexed(o);
|
|
for v in &verts {
|
|
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", v[0], v[1], v[2]);
|
|
}
|
|
x.push_str(" </vertices>\n <triangles>\n");
|
|
for (t, look) in &tris {
|
|
match look {
|
|
l if colored && *l > 0 && (*l as usize) <= o.looks.len() => {
|
|
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" pid=\"1\" p1=\"{}\"/>", t[0], t[1], t[2], own + *l as usize);
|
|
}
|
|
_ => {
|
|
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", t[0], t[1], t[2]);
|
|
}
|
|
}
|
|
}
|
|
x.push_str(" </triangles>\n </mesh>\n </object>\n");
|
|
}
|
|
x.push_str(" </resources>\n <build>\n");
|
|
for k in 0..objects.len() {
|
|
let _ = writeln!(x, " <item objectid=\"{}\"/>", k + 2);
|
|
}
|
|
x.push_str(" </build>\n</model>\n");
|
|
x
|
|
}
|
|
|
|
/// A 3MF package of meshes.
|
|
pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
|
|
if objects.is_empty() {
|
|
return Err(IoError::Empty);
|
|
}
|
|
let content_types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n</Types>\n";
|
|
let rels = format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n <Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" Type=\"{MODEL_REL}\"/>\n</Relationships>\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<T>(msg: impl Into<String>) -> Result<T> {
|
|
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
|
|
}
|
|
|
|
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
|
|
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<String, String> {
|
|
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<Xf> {
|
|
let Some(s) = s else { return Ok(IDENTITY) };
|
|
let v: Vec<f64> = s
|
|
.split_whitespace()
|
|
.map(|t| t.parse::<f64>().ok().filter(|x| x.is_finite() && x.abs() < 1e12))
|
|
.collect::<Option<_>>()
|
|
.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<Vec3>,
|
|
triangles: Vec<[u32; 3]>,
|
|
components: Vec<(u32, Xf)>,
|
|
material: Option<(u32, usize)>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Model {
|
|
unit: f64,
|
|
objects: HashMap<u32, Object>,
|
|
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
|
|
build: Vec<(u32, Xf)>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
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<f64> {
|
|
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<Model> {
|
|
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<u32> = 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::<f64>().ok()).filter(|v| v.is_finite());
|
|
let id = |k: &str| a.get(k).and_then(|v| v.trim().parse::<u32>().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::<usize>().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 <model> 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<u32>, 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<u32> = 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<MeshObject>, Vec<String>)> {
|
|
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<u8> {
|
|
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("<item ").count(), 2);
|
|
let (objs, w) = read_3mf(&bytes).unwrap();
|
|
assert!(w.is_empty());
|
|
assert_eq!(objs.len(), 2);
|
|
assert_eq!(objs[0].name, "A <&> \"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#"<?xml version="1.0"?>
|
|
<model unit="centimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>
|
|
<object id="1" type="model" name="Tet"><mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
|
|
<triangles><triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="1" v3="3"/><triangle v1="1" v2="2" v3="3"/><triangle v1="0" v2="3" v3="2"/></triangles></mesh></object>
|
|
<object id="2" type="model" name="Pair"><components><component objectid="1"/><component objectid="1" transform="1 0 0 0 1 0 0 0 1 5 0 0"/></components></object>
|
|
</resources><build><item objectid="2" transform="-1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#;
|
|
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<f64> = 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#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#;
|
|
let cases = [
|
|
format!(
|
|
"{head}<object id=\"1\"><components><component objectid=\"1\"/></components></object></resources><build><item objectid=\"1\"/></build></model>"
|
|
),
|
|
format!(
|
|
"{head}<object id=\"1\"><mesh><vertices><vertex x=\"0\" y=\"0\" z=\"0\"/></vertices><triangles><triangle v1=\"0\" v2=\"7\" v3=\"9\"/></triangles></mesh></object></resources><build><item objectid=\"1\"/></build></model>"
|
|
),
|
|
format!("{head}<object id=\"1\"><mesh><vertices><vertex x=\"NaN\" y=\"0\" z=\"0\"/></vertices></mesh></object></resources></model>"),
|
|
format!("{head}</resources><build><item objectid=\"9\"/></build></model>"),
|
|
format!("{head}<object id=\"1\"><components><component objectid=\"1\" transform=\"1 2\"/></components></object></resources></model>"),
|
|
r#"<model unit="parsec"/>"#.to_string(),
|
|
"<model><resources><object".to_string(),
|
|
format!("{}{}", "<a>".repeat(1000), "</a>".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("<model/>", Some("<Relationships><Relationship Type=\"x/3dmodel\" Target=\"/missing.model\"/></Relationships>")))
|
|
.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!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
|
|
let model = format!(
|
|
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
|
|
<triangles><triangle v1=\"0\" v2=\"1\" v3=\"2\"/></triangles></mesh></object></resources><build>{}</build></model>",
|
|
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#,
|
|
"<item objectid=\"1\"/>".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!("<vertex x=\"{}\" y=\"0\" z=\"0\"/>", i % 100)).collect();
|
|
let tris: String = (0..10_000).map(|i| format!("<triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", 3 * i, 3 * i + 1, 3 * i + 2)).collect();
|
|
let model = format!(
|
|
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh></object>\
|
|
</resources><build>{}</build></model>",
|
|
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#,
|
|
"<item objectid=\"1\"/>".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:?}");
|
|
}
|
|
}
|