3MF: export, and 3MF/STL import as mesh bodies
Export: ExportCommand/CLI --out/MCP export with .3mf write a 3MF Core package ([Content_Types].xml, _rels/.rels, 3D/3dmodel.model; millimetres), one mesh object and build item per body, named after it, vertices shared and welded watertight (T-junctions between faces split), base-material colours when bodies carry one (STEP colours now ride on bodies). Tessellation as for STL. File > Export 3MF. Import: doc.open/drag-drop/CLI/MCP open accept .3mf and .stl; Insert Mesh (ParaMeshInsertAlignCommand) adds them to a design as a MeshImport feature. Kernel mesh bodies render, measure exactly, move and export; solid features refuse them with a clear error. 3MF reading flattens components and build transforms, converts units, reads colours; sizes, entries, nesting and element counts are capped. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
043a542e29
commit
ea3dd0ed6e
30 files changed
+1361
-47
No files matched your search
+153
-4
@@ -1,11 +1,16 @@
|
||||
//! File formats. Designs are saved as JSON (`.solvecraft`): the parametric document only, the
|
||||
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ and STEP; STEP files
|
||||
//! import as a base feature (see [`step_import_feature`]).
|
||||
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ, STEP and 3MF; STEP
|
||||
//! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies
|
||||
//! (see [`mesh_import_feature`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod threemf;
|
||||
|
||||
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf};
|
||||
|
||||
use solvecraft_doc::{Document, ModelState};
|
||||
use solvecraft_geom::Mesh;
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
/// Design file extension.
|
||||
pub const DESIGN_EXT: &str = "solvecraft";
|
||||
@@ -14,7 +19,7 @@ pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IoError {
|
||||
#[error("unsupported format `{0}` (use stl, obj, step or solvecraft)")]
|
||||
#[error("unsupported format `{0}` (use stl, obj, step, 3mf or solvecraft)")]
|
||||
Format(String),
|
||||
#[error("nothing to export: the design has no bodies")]
|
||||
Empty,
|
||||
@@ -76,6 +81,64 @@ pub fn step_import_feature(bytes: &[u8], file: &str) -> Result<StepFeature> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Is this a mesh file we import (`.3mf`, `.stl`, any case)?
|
||||
pub fn is_mesh_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("3mf") || e.eq_ignore_ascii_case("stl"))
|
||||
}
|
||||
|
||||
/// A 3MF or STL file read as a mesh import feature.
|
||||
pub struct MeshFeature {
|
||||
/// Timeline name: the only mesh's name, else the file name.
|
||||
pub name: String,
|
||||
pub kind: solvecraft_doc::FeatureKind,
|
||||
pub body_names: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read 3MF or STL bytes (the format from `file`'s extension) into a MeshImport feature.
|
||||
pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result<MeshFeature> {
|
||||
if bytes.len() > MAX_3MF_BYTES {
|
||||
return Err(IoError::Invalid(format!("mesh file too large ({} MB)", bytes.len() >> 20)));
|
||||
}
|
||||
let path = std::path::Path::new(file);
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "Mesh".into());
|
||||
let is_stl = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("stl"));
|
||||
let (objects, warnings) = if is_stl {
|
||||
let tris = read_stl(bytes)?;
|
||||
if tris.is_empty() {
|
||||
return Err(IoError::Invalid("STL has no triangles".into()));
|
||||
}
|
||||
let positions: Vec<Vec3> = tris.iter().flatten().map(|v| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64)).collect();
|
||||
let n = u32::try_from(tris.len()).map_err(|_| IoError::Invalid("STL too large".into()))?;
|
||||
let triangles = (0..n).map(|i| [i * 3, i * 3 + 1, i * 3 + 2]).collect();
|
||||
(vec![MeshObject { name: stem.clone(), positions, triangles, color: None }], Vec::new())
|
||||
} else {
|
||||
read_3mf(bytes)?
|
||||
};
|
||||
// Check every mesh now, so the command fails rather than the timeline.
|
||||
let mut meshes = Vec::with_capacity(objects.len());
|
||||
for o in &objects {
|
||||
solvecraft_kernel::mesh_body(&o.positions, &o.triangles).map_err(|e| IoError::Invalid(format!("mesh `{}`: {e}", o.name)))?;
|
||||
meshes.push(solvecraft_doc::MeshData {
|
||||
name: o.name.clone(),
|
||||
positions: o.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: o.triangles.iter().flatten().copied().collect(),
|
||||
color: o.color,
|
||||
});
|
||||
}
|
||||
let name = match objects.as_slice() {
|
||||
[only] if !only.name.trim().is_empty() => only.name.clone(),
|
||||
_ => stem,
|
||||
};
|
||||
let file_name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string());
|
||||
Ok(MeshFeature {
|
||||
name,
|
||||
body_names: objects.iter().map(|o| o.name.clone()).collect(),
|
||||
kind: solvecraft_doc::FeatureKind::MeshImport { file: file_name, meshes },
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Export formats.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
@@ -83,6 +146,7 @@ pub enum Format {
|
||||
StlAscii,
|
||||
Obj,
|
||||
Step,
|
||||
ThreeMf,
|
||||
Design,
|
||||
}
|
||||
|
||||
@@ -95,6 +159,7 @@ impl Format {
|
||||
"stla" | "stl-ascii" => Format::StlAscii,
|
||||
"obj" => Format::Obj,
|
||||
"step" | "stp" => Format::Step,
|
||||
"3mf" => Format::ThreeMf,
|
||||
"solvecraft" | "json" => Format::Design,
|
||||
_ => return Err(IoError::Format(ext)),
|
||||
})
|
||||
@@ -188,6 +253,18 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
|
||||
let bs: Vec<&solvecraft_kernel::Body> = sel.iter().map(|b| &b.body).collect();
|
||||
solvecraft_kernel::step_export(&bs, "SolveCraft")?.into_bytes()
|
||||
}
|
||||
Format::ThreeMf => {
|
||||
let objs: Vec<MeshObject> = sel
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = export_mesh(&b.body)?;
|
||||
let eps = (b.body.size() * 1e-6).max(1e-7);
|
||||
let (positions, triangles) = threemf::weld(&m.positions, &m.triangles, eps);
|
||||
Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color() })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
write_3mf(&objs)?
|
||||
}
|
||||
Format::Design => return Err(IoError::Format("solvecraft (save the document instead)".into())),
|
||||
})
|
||||
}
|
||||
@@ -301,4 +378,76 @@ mod tests {
|
||||
fake[80..84].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(read_stl(&fake).is_err());
|
||||
}
|
||||
|
||||
fn stl_volume(bytes: &[u8]) -> f64 {
|
||||
read_stl(bytes)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let p = |k: usize| Vec3::new(t[k][0] as f64, t[k][1] as f64, t[k][2] as f64);
|
||||
p(0).dot(p(1).cross(p(2))) / 6.0
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Build → export 3MF → import → the same volume as the STL export, closed meshes, names.
|
||||
#[test]
|
||||
fn threemf_round_trip() {
|
||||
let mut doc = Document::new("t");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 5.0)], radius: "4".into(), body: None }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(80.0, 0.0, 0.0),
|
||||
axis: Vec3::Z,
|
||||
radius: "6".into(),
|
||||
height: "15".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
let st = m.state();
|
||||
assert_eq!(st.bodies.len(), 3);
|
||||
let bytes = export(&st, &[], Format::ThreeMf, "t").unwrap();
|
||||
let f = mesh_import_feature(&bytes, "/tmp/parts.3MF").unwrap();
|
||||
assert_eq!(f.body_names, st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
|
||||
let mut d2 = Document::new("m");
|
||||
let id = d2.add_feature(f.kind, Some(&f.name)).unwrap();
|
||||
let mut m2 = Model::new();
|
||||
m2.evaluate(&d2);
|
||||
assert!(m2.result(id).unwrap().error.is_none());
|
||||
let st2 = m2.state();
|
||||
assert_eq!(st2.bodies.len(), 3);
|
||||
for (a, b) in st.bodies.iter().zip(&st2.bodies) {
|
||||
assert_eq!(a.name, b.name);
|
||||
assert!(b.body.is_mesh() && b.body.is_closed_mesh(), "{} is not watertight", b.name);
|
||||
let v_stl = stl_volume(&export(&st, std::slice::from_ref(&a.name), Format::StlBinary, "t").unwrap());
|
||||
let v = solvecraft_kernel::measure(&b.body).unwrap().volume;
|
||||
assert!((v - v_stl).abs() <= 1e-6 * v_stl.abs(), "{}: {v} vs {v_stl}", a.name);
|
||||
}
|
||||
// Mesh bodies export again (3MF, STL, OBJ) but not as STEP.
|
||||
assert!(export(&st2, &[], Format::ThreeMf, "t").is_ok());
|
||||
assert!(export(&st2, &[], Format::StlAscii, "t").is_ok());
|
||||
assert!(export(&st2, &[], Format::Obj, "t").is_ok());
|
||||
assert!(export(&st2, &[], Format::Step, "t").is_err());
|
||||
// STL imports the same way.
|
||||
let stl = export(&st, &[], Format::StlBinary, "t").unwrap();
|
||||
let fs = mesh_import_feature(&stl, "all.stl").unwrap();
|
||||
assert_eq!(fs.name, "all");
|
||||
assert!(mesh_import_feature(b"PK", "x.3mf").is_err());
|
||||
assert!(mesh_import_feature(&[0u8; 84], "x.stl").is_err());
|
||||
assert!(is_mesh_path("a.3MF") && is_mesh_path("b.stl") && !is_mesh_path("c.step"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
//! 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]>,
|
||||
}
|
||||
|
||||
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]>) {
|
||||
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()
|
||||
.filter_map(|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])
|
||||
})
|
||||
.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]>) {
|
||||
// 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: Vec<[u32; 3]> = triangles
|
||||
.iter()
|
||||
.filter_map(|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])
|
||||
})
|
||||
.collect();
|
||||
// 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);
|
||||
for t in &tris {
|
||||
// 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]]);
|
||||
prev = *v;
|
||||
}
|
||||
next.push([prev, r[1], r[2]]);
|
||||
}
|
||||
None => next.push(*t),
|
||||
}
|
||||
}
|
||||
tris = next;
|
||||
}
|
||||
(verts, tris)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
// Base material group id 1; objects from 2.
|
||||
if colored {
|
||||
x.push_str(" <basematerials id=\"1\">\n");
|
||||
for o in objects {
|
||||
let c = o.color.unwrap_or([0.7, 0.7, 0.7]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let _ = writeln!(x, " <base name=\"{}\" displaycolor=\"#{:02X}{:02X}{:02X}\"/>", esc(&o.name), c[0], c[1], c[2]);
|
||||
}
|
||||
x.push_str(" </basematerials>\n");
|
||||
}
|
||||
for (k, o) in objects.iter().enumerate() {
|
||||
let id = k + 2;
|
||||
let mat = if colored { format!(" pid=\"1\" pindex=\"{k}\"") } 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 in &tris {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user