Files
OneCAD/crates/io/src/threemf.rs
T
bherbruckandClaude Opus 5.5 294597cc96 Face colours and opacity in STEP and 3MF export (and STEP import)
- Bodies carry a Paint for export (opacity, per-face colours), set by
  Document::painted from the appearance model. The paint comes from a
  face appearance, a body/component appearance's opacity or a material
  look (Glass), and falls back to the paint the body was imported with.
- STEP: faces with colours of their own get a STYLED_ITEM on their
  ADVANCED_FACE. See-through looks add SURFACE_STYLE_TRANSPARENT.
  Differently coloured pieces are not merged into one face.
- STEP import reads face styles and transparency. The reader tracks the
  file face of each kernel face (bands split in two keep it), so imported
  face colours land on the right faces; the design shows them through
  face_colors.
- 3MF: per-triangle colours (pid/p1 into the base materials, carried
  through welding) and the object's alpha (#RRGGBBAA).
- Tests: a blue half-transparent cube with a red top exports, re-imports
  with its opacity and the red top face, and keeps it when opened and
  exported again. 3MF writes the alpha and the face entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:02:10 +00:00

738 lines
32 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("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&apos;"),
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\">SolveCraft</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 += o.triangles.len();
if *total > MAX_ELEMENTS {
return bad("too many triangles after placing components");
}
let base = u32::try_from(out.positions.len()).map_err(|_| IoError::Invalid("3MF: too many vertices".into()))?;
let n = o.positions.len();
out.positions.extend(o.positions.iter().map(|p| apply(xf, *p) * m.unit));
let flip = det(xf) < 0.0;
for t in &o.triangles {
if t.iter().any(|v| *v as usize >= n) {
return bad(format!("object {id}: triangle refers to a missing vertex"));
}
let t = t.map(|v| v + base);
out.triangles.push(if flip { [t[0], t[2], t[1]] } else { t });
}
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 &lt;&amp;&gt; &quot;b&quot;\""));
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()
);
}
}