use super::{MAX_INPUT_BYTES, transform::Transform};
use crate::{
Result,
scene::{
Scene, Texture, TextureFilter, TextureInterpolationSpace, TextureWrap, Triangle,
TriangleAppearance, Vec3,
},
};
use image::{ImageFormat, RgbaImage};
use std::{
collections::{BTreeMap, HashMap, HashSet},
io::{Cursor, Read, Seek, SeekFrom},
path::Path,
sync::Arc,
};
const CORE: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
const MATERIAL: &str = "http://schemas.microsoft.com/3dmanufacturing/material/2015/02";
const PRODUCTION: &str = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06";
const RELATIONSHIPS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const MODEL_RELATIONSHIP: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel";
const TEXTURE_RELATIONSHIP: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dtexture";
const MAX_TEXTURE_AXIS: u32 = 8192;
const MAX_TEXTURE_PIXELS: u64 = 16 * 1024 * 1024;
const MAX_TOTAL_TEXTURE_PIXELS: u64 = 64 * 1024 * 1024;
const MAX_TRIANGLES: usize = 5_000_000;
const MAX_PARTS: usize = 256;
const MAX_RESOURCES: usize = 100_000;
const MAX_REFERENCES: usize = 1_000_000;
const MAX_DEPTH: usize = 256;
struct Reference {
part: Option<String>,
object: u32,
transform: Transform,
}
struct Object {
triangles: Vec<Triangle>,
textures: BTreeMap<usize, TextureMapping>,
components: Vec<Reference>,
}
struct ModelPart {
objects: HashMap<u32, Object>,
build: Vec<Reference>,
textures: HashMap<u32, TextureResource>,
}
struct TextureResource {
path: String,
format: ImageFormat,
wrap_s: TextureWrap,
wrap_t: TextureWrap,
filter: TextureFilter,
image: Option<Arc<RgbaImage>>,
}
struct TextureGroup {
texture: u32,
coordinates: Vec<[f64; 2]>,
}
struct TextureMapping {
texture: u32,
coordinates: [[f64; 2]; 3],
}
struct CachedImage {
format: ImageFormat,
image: Arc<RgbaImage>,
}
#[derive(Default)]
struct Budget {
bytes: u64,
pixels: u64,
vertices: usize,
triangles: usize,
resources: usize,
references: usize,
texcoords: usize,
}
fn consume(count: &mut usize, limit: usize, label: &str) -> Result<()> {
*count = count.checked_add(1).ok_or("3MF count overflow")?;
if *count > limit {
return Err(format!("3MF exceeds {label} limit").into());
}
Ok(())
}
fn zip_number<const N: usize>(bytes: &[u8], offset: usize) -> Result<[u8; N]> {
Ok(bytes
.get(offset..)
.and_then(|tail| tail.get(..N))
.ok_or("truncated 3MF ZIP directory field")?
.try_into()?)
}
fn zip_u16(bytes: &[u8], offset: usize) -> Result<u16> {
Ok(u16::from_le_bytes(zip_number(bytes, offset)?))
}
fn zip_u32(bytes: &[u8], offset: usize) -> Result<u32> {
Ok(u32::from_le_bytes(zip_number(bytes, offset)?))
}
fn zip_u64(bytes: &[u8], offset: usize) -> Result<u64> {
Ok(u64::from_le_bytes(zip_number(bytes, offset)?))
}
fn zip_read(reader: &mut (impl Read + Seek), offset: u64, length: usize) -> Result<Vec<u8>> {
reader.seek(SeekFrom::Start(offset))?;
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes)?;
Ok(bytes)
}
fn zip64_end(
reader: &mut (impl Read + Seek),
locator: u64,
declared: u64,
) -> Result<(u64, Vec<u8>)> {
for offset in [declared, locator.saturating_sub(56)] {
if offset.checked_add(56).is_none_or(|end| end > locator) {
continue;
}
let header = zip_read(reader, offset, 56)?;
if &header[..4] == b"PK\x06\x06" {
let size = zip_u64(&header, 4)?;
if size >= 44
&& offset.checked_add(size).and_then(|n| n.checked_add(12)) == Some(locator)
{
return Ok((offset, header));
}
}
}
let mut end = locator;
while end > declared {
let start = end.saturating_sub(65_536).max(declared);
let bytes = zip_read(reader, start, usize::try_from(end - start)?)?;
for (index, header) in bytes.windows(56).enumerate().rev() {
if &header[..4] != b"PK\x06\x06" {
continue;
}
let offset = start + index as u64;
let size = zip_u64(header, 4)?;
if size >= 44
&& offset.checked_add(size).and_then(|n| n.checked_add(12)) == Some(locator)
{
return Ok((offset, header.to_vec()));
}
}
if start == declared {
break;
}
end = start + 55;
}
Err("invalid 3MF ZIP64 end-of-directory record".into())
}
#[allow(
clippy::too_many_lines,
reason = "Keep the format record dispatch, validation and its resource checks together without changing geometry evaluation."
)]
fn validate_zip_directory(reader: &mut (impl Read + Seek), length: u64) -> Result<usize> {
if !(22..=MAX_INPUT_BYTES).contains(&length) {
return Err("invalid or oversized 3MF ZIP package".into());
}
let tail_length = length.min(65_557);
let tail_start = length - tail_length;
let tail = zip_read(reader, tail_start, usize::try_from(tail_length)?)?;
let (index, eocd) = tail
.windows(22)
.enumerate()
.rev()
.find(|(index, record)| {
&record[..4] == b"PK\x05\x06"
&& index + 22 + usize::from(u16::from_le_bytes([record[20], record[21]]))
== tail.len()
})
.ok_or("3MF ZIP end-of-directory record is missing")?;
let eocd_start = tail_start + index as u64;
let disk = zip_u16(eocd, 4)?;
let directory_disk = zip_u16(eocd, 6)?;
let disk_entries = zip_u16(eocd, 8)?;
let entries = zip_u16(eocd, 10)?;
let size = zip_u32(eocd, 12)?;
let offset = zip_u32(eocd, 16)?;
let needs_zip64 = [disk, directory_disk, disk_entries, entries].contains(&u16::MAX)
|| [size, offset].contains(&u32::MAX);
let locator = if eocd_start >= 20 {
zip_read(reader, eocd_start - 20, 20)?
} else {
Vec::new()
};
let (entries, size, offset, directory_end) = if locator.starts_with(b"PK\x06\x07") {
if zip_u32(&locator, 4)? != 0 || zip_u32(&locator, 16)? != 1 {
return Err("multi-disk 3MF ZIP packages are unsupported".into());
}
let declared = zip_u64(&locator, 8)?;
let (end_start, record) = zip64_end(reader, eocd_start - 20, declared)?;
if declared > end_start
|| zip_u32(&record, 16)? != 0
|| zip_u32(&record, 20)? != 0
|| zip_u64(&record, 24)? != zip_u64(&record, 32)?
{
return Err("invalid or multi-disk 3MF ZIP64 directory".into());
}
let full_entries = zip_u64(&record, 32)?;
let full_size = zip_u64(&record, 40)?;
let full_offset = zip_u64(&record, 48)?;
for (legacy, sentinel, actual) in [
(u64::from(disk), u64::from(u16::MAX), 0),
(u64::from(directory_disk), u64::from(u16::MAX), 0),
(u64::from(disk_entries), u64::from(u16::MAX), full_entries),
(u64::from(entries), u64::from(u16::MAX), full_entries),
(u64::from(size), u64::from(u32::MAX), full_size),
(u64::from(offset), u64::from(u32::MAX), full_offset),
] {
if legacy != sentinel && legacy != actual {
return Err("inconsistent 3MF ZIP and ZIP64 directory fields".into());
}
}
(full_entries, full_size, full_offset, end_start)
} else {
if needs_zip64 {
return Err("3MF ZIP64 locator is missing".into());
}
if disk != 0 || directory_disk != 0 || disk_entries != entries {
return Err("multi-disk 3MF ZIP packages are unsupported".into());
}
(
u64::from(entries),
u64::from(size),
u64::from(offset),
eocd_start,
)
};
if entries > MAX_RESOURCES as u64 {
return Err("3MF package has too many archive entries".into());
}
let directory_start = directory_end
.checked_sub(size)
.filter(|&start| start >= offset)
.ok_or("invalid 3MF ZIP central-directory extent")?;
let mut position = directory_start;
let mut names = HashSet::new();
for _ in 0..entries {
if position
.checked_add(46)
.is_none_or(|end| end > directory_end)
{
return Err("truncated 3MF ZIP central-directory header".into());
}
let header = zip_read(reader, position, 46)?;
if !header.starts_with(b"PK\x01\x02") {
return Err("invalid 3MF ZIP central-directory header".into());
}
let name_length = u64::from(zip_u16(&header, 28)?);
let extra_length = u64::from(zip_u16(&header, 30)?);
let comment_length = u64::from(zip_u16(&header, 32)?);
let next = position
.checked_add(46 + name_length + extra_length + comment_length)
.filter(|&end| end <= directory_end)
.ok_or("truncated 3MF ZIP central-directory entry")?;
let name = zip_read(reader, position + 46, usize::try_from(name_length)?)?;
if !names.insert(name) {
return Err("duplicate 3MF package part name".into());
}
position = next;
}
if position != directory_end {
if position
.checked_add(6)
.is_none_or(|end| end > directory_end)
{
return Err("3MF ZIP directory size or entry count is inconsistent".into());
}
let signature = zip_read(reader, position, 6)?;
if !signature.starts_with(b"PK\x05\x05")
|| position + 6 + u64::from(zip_u16(&signature, 4)?) != directory_end
{
return Err("3MF ZIP directory size or entry count is inconsistent".into());
}
}
Ok(usize::try_from(entries)?)
}
struct Package {
archive: zip::ZipArchive<std::fs::File>,
entries: HashMap<String, usize>,
images: HashMap<String, CachedImage>,
}
impl Package {
fn open(path: &Path) -> Result<Self> {
let mut file = std::fs::File::open(path)?;
let length = file.metadata()?.len();
if length > MAX_INPUT_BYTES {
return Err("3MF package exceeds the 512 MiB limit".into());
}
let physical_entries = validate_zip_directory(&mut file, length)?;
let mut archive = zip::ZipArchive::new(file)?;
if archive.len() != physical_entries {
return Err("duplicate decoded 3MF package part name".into());
}
if archive.len() > MAX_RESOURCES {
return Err("3MF package has too many archive entries".into());
}
let mut entries = HashMap::new();
for index in 0..archive.len() {
let entry = archive.by_index(index)?;
if entry.is_dir() {
continue;
}
let path = part_name(entry.name())?;
if entries.insert(path, index).is_some() {
return Err("duplicate 3MF package part name".into());
}
}
Ok(Self {
archive,
entries,
images: HashMap::new(),
})
}
fn contains(&self, path: &str) -> bool {
self.entries.contains_key(path)
}
fn read(&mut self, path: &str, budget: &mut Budget) -> Result<String> {
Ok(String::from_utf8(self.read_bytes(path, budget)?)?)
}
fn read_bytes(&mut self, path: &str, budget: &mut Budget) -> Result<Vec<u8>> {
let index = *self
.entries
.get(path)
.ok_or_else(|| format!("3MF references a missing package part {path}"))?;
let mut entry = self.archive.by_index(index)?;
if entry.size() > MAX_INPUT_BYTES.saturating_sub(budget.bytes) {
return Err("3MF consumed package data exceeds the aggregate 512 MiB limit".into());
}
let mut bytes = Vec::new();
entry
.by_ref()
.take(MAX_INPUT_BYTES.saturating_sub(budget.bytes) + 1)
.read_to_end(&mut bytes)?;
budget.bytes = budget
.bytes
.checked_add(bytes.len() as u64)
.filter(|&n| n <= MAX_INPUT_BYTES)
.ok_or("3MF consumed package data exceeds the aggregate 512 MiB limit")?;
Ok(bytes)
}
fn texture(
&mut self,
path: &str,
format: ImageFormat,
budget: &mut Budget,
) -> Result<Arc<RgbaImage>> {
if let Some(cached) = self.images.get(path) {
if cached.format != format {
return Err("3MF image signature differs from its texture content type".into());
}
return Ok(Arc::clone(&cached.image));
}
let bytes = self.read_bytes(path, budget)?;
if image::guess_format(&bytes)? != format {
return Err("3MF image signature differs from its texture content type".into());
}
let (width, height) =
texture_reader(&bytes, format)
.into_dimensions()
.map_err(|error| {
format!("3MF texture metadata or dimensions exceed limits: {error}")
})?;
let pixels = u64::from(width) * u64::from(height);
budget.pixels = budget
.pixels
.checked_add(pixels)
.ok_or("3MF texture pixel count overflow")?;
if width == 0
|| height == 0
|| width > MAX_TEXTURE_AXIS
|| height > MAX_TEXTURE_AXIS
|| pixels > MAX_TEXTURE_PIXELS
|| budget.pixels > MAX_TOTAL_TEXTURE_PIXELS
{
return Err("3MF textures exceed the dimension or aggregate pixel budget".into());
}
let image = Arc::new(texture_reader(&bytes, format).decode()?.to_rgba8());
self.images.insert(
path.to_owned(),
CachedImage {
format,
image: Arc::clone(&image),
},
);
Ok(image)
}
}
fn texture_reader(bytes: &[u8], format: ImageFormat) -> image::ImageReader<Cursor<&[u8]>> {
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_TEXTURE_AXIS);
limits.max_image_height = Some(MAX_TEXTURE_AXIS);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
reader
}
pub(super) fn load(path: &Path) -> Result<Scene> {
let mut package = Package::open(path)?;
let mut budget = Budget::default();
let root = if package.contains("_rels/.rels") {
let xml = package.read("_rels/.rels", &mut budget)?;
let targets = model_relationships(&xml, "")?;
if targets.len() != 1 {
return Err("3MF package must have exactly one root model relationship".into());
}
targets
.into_iter()
.next()
.ok_or("3MF root model is missing")?
} else {
package_path("3D/3dmodel.model")?
};
let xml = package.read(&root, &mut budget)?;
let mut root_part = parse_part(&xml, true, &mut budget)?;
load_textures(&mut package, &root, &mut root_part, &mut budget)?;
let mut targets: HashSet<_> = root_part
.objects
.values()
.flat_map(|object| &object.components)
.chain(&root_part.build)
.filter_map(|reference| reference.part.clone())
.collect();
if targets.len() >= MAX_PARTS {
return Err("3MF package exceeds the model-part limit".into());
}
if !targets.is_empty() {
let (directory, name) = root.rsplit_once('/').unwrap_or(("", root.as_str()));
let relationships = if directory.is_empty() {
format!("_rels/{name}.rels")
} else {
format!("{directory}/_rels/{name}.rels")
};
let xml = package.read(&relationships, &mut budget)?;
let declared = model_relationships(&xml, directory)?;
if targets.iter().any(|target| !declared.contains(target)) {
return Err("3MF production part lacks a model relationship".into());
}
}
targets.remove(&root);
let mut targets: Vec<_> = targets.into_iter().collect();
targets.sort();
let mut parts = HashMap::from([(root.clone(), root_part)]);
for target in targets {
let xml = package.read(&target, &mut budget)?;
let mut part = parse_part(&xml, false, &mut budget)?;
load_textures(&mut package, &target, &mut part, &mut budget)?;
parts.insert(target, part);
}
render_parts(&root, &parts)
}
fn decode_uri(target: &str) -> Result<String> {
let bytes = target.as_bytes();
let mut decoded = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return Err("invalid escape in 3MF package target".into());
}
decoded.push(u8::from_str_radix(
std::str::from_utf8(&bytes[i + 1..i + 3])?,
16,
)?);
i += 3;
} else {
decoded.push(bytes[i]);
i += 1;
}
}
Ok(String::from_utf8(decoded)?)
}
fn part_name(value: &str) -> Result<String> {
let path = value.strip_prefix('/').unwrap_or(value);
if value.contains([':', '\0', '\\', '?', '#'])
|| path.is_empty()
|| path.split('/').any(|p| matches!(p, "" | "." | ".."))
{
return Err("invalid 3MF package target".into());
}
Ok(path.to_ascii_lowercase())
}
fn package_path(target: &str) -> Result<String> {
part_name(&decode_uri(target)?)
}
fn relationship_target(target: &str, directory: &str) -> Result<String> {
let target = decode_uri(target)?;
if target.contains([':', '\0', '\\', '?', '#']) || target.starts_with("//") {
return Err("invalid 3MF relationship target".into());
}
let mut segments: Vec<_> = if target.starts_with('/') || directory.is_empty() {
Vec::new()
} else {
directory.split('/').collect()
};
for segment in target.strip_prefix('/').unwrap_or(&target).split('/') {
match segment {
"" => return Err("invalid 3MF relationship target".into()),
"." => {}
".." => {
segments
.pop()
.ok_or("3MF relationship escapes the package")?;
}
value => segments.push(value),
}
}
part_name(&segments.join("/"))
}
fn model_relationships(xml: &str, directory: &str) -> Result<HashSet<String>> {
relationship_targets(xml, directory, MODEL_RELATIONSHIP, MAX_PARTS, "model")
}
fn relationship_targets(
xml: &str,
directory: &str,
kind: &str,
limit: usize,
label: &str,
) -> Result<HashSet<String>> {
let doc = roxmltree::Document::parse(xml)?;
let root = doc.root_element();
if !root.has_tag_name((RELATIONSHIPS, "Relationships")) {
return Err("expected 3MF OPC Relationships element".into());
}
let mut ids = HashSet::new();
let mut targets = HashSet::new();
for relation in root
.children()
.filter(|n| n.has_tag_name((RELATIONSHIPS, "Relationship")))
{
if !ids.insert(attribute(relation, "Id")?) {
return Err("duplicate 3MF relationship ID".into());
}
if relation.attribute("Type") != Some(kind) {
continue;
}
if relation
.attribute("TargetMode")
.is_some_and(|mode| mode != "Internal")
{
return Err(format!("external 3MF {label} relationships are unsupported").into());
}
let target = attribute(relation, "Target")?;
let target = relationship_target(target, directory)?;
if !targets.insert(target) {
return Err(format!("duplicate 3MF {label} relationship target").into());
}
if targets.len() > limit {
return Err(format!("3MF relationships exceed the {label} target limit").into());
}
}
Ok(targets)
}
fn load_textures(
package: &mut Package,
owner: &str,
part: &mut ModelPart,
budget: &mut Budget,
) -> Result<()> {
if part.textures.is_empty() {
return Ok(());
}
let (directory, name) = owner.rsplit_once('/').unwrap_or(("", owner));
let relationships = if directory.is_empty() {
format!("_rels/{name}.rels")
} else {
format!("{directory}/_rels/{name}.rels")
};
let xml = package.read(&relationships, budget)?;
let declared = relationship_targets(
&xml,
directory,
TEXTURE_RELATIONSHIP,
MAX_RESOURCES,
"texture",
)?;
let mut identifiers: Vec<_> = part.textures.keys().copied().collect();
identifiers.sort_unstable();
for identifier in identifiers {
let texture = part
.textures
.get_mut(&identifier)
.ok_or("3MF texture resource is missing")?;
if !declared.contains(&texture.path) {
return Err("3MF texture lacks an owning-model texture relationship".into());
}
texture.image = Some(package.texture(&texture.path, texture.format, budget)?);
}
Ok(())
}
fn texture_wrap(value: Option<&str>) -> Result<TextureWrap> {
match value.unwrap_or("wrap") {
"wrap" => Ok(TextureWrap::Repeat),
"mirror" => Ok(TextureWrap::MirroredRepeat),
"clamp" | "none" => Ok(TextureWrap::ClampToEdge),
_ => Err("invalid 3MF texture tile style".into()),
}
}
fn texture_resource(node: roxmltree::Node<'_, '_>) -> Result<TextureResource> {
if !node.has_tag_name((MATERIAL, "texture2d")) {
return Err("3MF texture2d must use the materials namespace".into());
}
let path = attribute(node, "path")?;
if !path.starts_with('/') {
return Err("3MF texture path must be an absolute package part name".into());
}
let format = match attribute(node, "contenttype")? {
"image/png" => ImageFormat::Png,
"image/jpeg" => ImageFormat::Jpeg,
_ => return Err("3MF texture content type must be image/png or image/jpeg".into()),
};
let filter = match node.attribute("filter").unwrap_or("auto") {
"auto" | "linear" => TextureFilter::Linear,
"nearest" => TextureFilter::Nearest,
_ => return Err("invalid 3MF texture filter".into()),
};
Ok(TextureResource {
path: package_path(path)?,
format,
wrap_s: texture_wrap(node.attribute("tilestyleu"))?,
wrap_t: texture_wrap(node.attribute("tilestylev"))?,
filter,
image: None,
})
}
fn texture_group(node: roxmltree::Node<'_, '_>, budget: &mut Budget) -> Result<TextureGroup> {
if !node.has_tag_name((MATERIAL, "texture2dgroup")) {
return Err("3MF texture2dgroup must use the materials namespace".into());
}
if node.attribute("displaypropertiesid").is_some() {
return Err("3MF texture display properties are not implemented".into());
}
let mut coordinates = Vec::new();
for coordinate in node.children().filter(roxmltree::Node::is_element) {
if !coordinate.has_tag_name((MATERIAL, "tex2coord")) {
return Err("3MF texture group contains an invalid coordinate element".into());
}
consume(&mut budget.texcoords, MAX_TRIANGLES, "texture coordinate")?;
let u: f64 = attribute(coordinate, "u")?.parse()?;
let v: f64 = attribute(coordinate, "v")?.parse()?;
let uv = [u, 1.0 - v];
if !u.is_finite() || !v.is_finite() || !uv[1].is_finite() {
return Err("3MF texture coordinates must be finite".into());
}
coordinates.push(uv);
}
if coordinates.is_empty() {
return Err("3MF texture coordinate group must not be empty".into());
}
Ok(TextureGroup {
texture: id(node, "texid")?,
coordinates,
})
}
fn attribute<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Result<&'a str> {
node.attribute(name)
.ok_or_else(|| format!("3MF {} is missing {name}", node.tag_name().name()).into())
}
fn id(node: roxmltree::Node<'_, '_>, name: &str) -> Result<u32> {
Ok(attribute(node, name)?.parse()?)
}
fn core_child<'a, 'input>(
node: roxmltree::Node<'a, 'input>,
name: &str,
) -> Result<Option<roxmltree::Node<'a, 'input>>> {
let mut matches = node.children().filter(|n| n.has_tag_name((CORE, name)));
let child = matches.next();
if matches.next().is_some() {
return Err(format!("duplicate 3MF {name} element").into());
}
Ok(child)
}
fn color(value: &str) -> Result<[u8; 3]> {
let value = value
.strip_prefix('#')
.ok_or("3MF color must start with #")?;
if (value.len() != 6 && value.len() != 8) || !value.is_ascii() {
return Err("3MF color must be #RRGGBB or #RRGGBBAA".into());
}
Ok([
u8::from_str_radix(&value[0..2], 16)?,
u8::from_str_radix(&value[2..4], 16)?,
u8::from_str_radix(&value[4..6], 16)?,
])
}
fn unit_scale(model: roxmltree::Node<'_, '_>) -> Result<f64> {
match model.attribute("unit").unwrap_or("millimeter") {
"micron" => Ok(0.001),
"millimeter" => Ok(1.),
"centimeter" => Ok(10.),
"inch" => Ok(25.4),
"foot" => Ok(304.8),
"meter" => Ok(1000.),
_ => Err("unsupported 3MF model unit".into()),
}
}
fn reference(
node: roxmltree::Node<'_, '_>,
root: bool,
unit: f64,
budget: &mut Budget,
) -> Result<Reference> {
consume(&mut budget.references, MAX_REFERENCES, "object reference")?;
let mut part = None;
for value in node.attributes().filter(|value| value.name() == "path") {
if value.namespace() != Some(PRODUCTION) {
return Err("3MF path attribute must use the production namespace".into());
}
if !root {
return Err("3MF production paths are allowed only in the root model part".into());
}
if !value.value().starts_with('/') {
return Err("3MF production path must be absolute within the package".into());
}
part = Some(package_path(value.value())?);
}
let mut transform = Transform::from_3mf(node.attribute("transform"))?;
for translation in &mut transform.0[3][..3] {
*translation *= unit;
}
if transform.0.iter().flatten().any(|n| !n.is_finite()) {
return Err("3MF transform exceeds numeric range".into());
}
Ok(Reference {
part,
object: id(node, "objectid")?,
transform,
})
}
#[allow(
clippy::cast_possible_truncation,
reason = "The mean of three bounded RGB bytes remains within the byte range."
)]
#[allow(
clippy::too_many_lines,
reason = "Keep the format record dispatch, validation and its resource checks together without changing geometry evaluation."
)]
fn parse_part(xml: &str, root: bool, budget: &mut Budget) -> Result<ModelPart> {
let doc = roxmltree::Document::parse(xml)?;
let model = doc.root_element();
if !model.has_tag_name((CORE, "model")) {
return Err("expected a 3MF core model element".into());
}
if let Some(extensions) = model.attribute("requiredextensions") {
for prefix in extensions.split_whitespace() {
let namespace = model.lookup_namespace_uri(Some(prefix));
if !matches!(namespace, Some(MATERIAL | PRODUCTION)) {
return Err(format!("required 3MF extension {prefix} is not implemented").into());
}
}
}
let unit = unit_scale(model)?;
let resources = core_child(model, "resources")?.ok_or("3MF model has no resources")?;
let mut all_ids = HashSet::new();
let mut colors: HashMap<u32, Vec<[u8; 3]>> = HashMap::new();
let mut textures = HashMap::new();
let mut groups = HashMap::new();
for resource in resources.children().filter(roxmltree::Node::is_element) {
consume(&mut budget.resources, MAX_RESOURCES, "resource")?;
let resource_id = id(resource, "id")?;
if resource_id == 0 {
return Err("3MF resource IDs must be positive".into());
}
if !all_ids.insert(resource_id) {
return Err("duplicate 3MF resource ID".into());
}
match resource.tag_name().name() {
"basematerials" | "colorgroup" => {
let palette = resource
.children()
.filter(roxmltree::Node::is_element)
.map(|n| {
color(attribute(
n,
if n.tag_name().name() == "base" {
"displaycolor"
} else {
"color"
},
)?)
})
.collect::<Result<Vec<_>>>()?;
colors.insert(resource_id, palette);
}
"object" => {}
"texture2d" => {
textures.insert(resource_id, texture_resource(resource)?);
}
"texture2dgroup" => {
groups.insert(resource_id, texture_group(resource, budget)?);
}
"compositematerials" | "multiproperties" => {
return Err(
"3MF composite materials and multiproperties are not implemented".into(),
);
}
_ => {
return Err(
format!("unsupported 3MF resource {}", resource.tag_name().name()).into(),
);
}
}
}
if groups
.values()
.any(|group| !textures.contains_key(&group.texture))
{
return Err("3MF texture coordinate group references a missing texture".into());
}
let mut objects = HashMap::new();
for resource in resources
.children()
.filter(|n| n.has_tag_name((CORE, "object")))
{
let object_id = id(resource, "id")?;
let property_group: Option<u32> = resource.attribute("pid").map(str::parse).transpose()?;
let object_pindex: usize = resource
.attribute("pindex")
.map(str::parse)
.transpose()?
.unwrap_or(0);
let mut object = Object {
triangles: Vec::new(),
textures: BTreeMap::new(),
components: Vec::new(),
};
let mesh = core_child(resource, "mesh")?;
let components = core_child(resource, "components")?;
if mesh.is_some() && components.is_some() {
return Err("3MF object cannot contain both mesh and components".into());
}
if let Some(mesh) = mesh {
let vertices_node = core_child(mesh, "vertices")?.ok_or("3MF mesh has no vertices")?;
let mut vertices = Vec::new();
for node in vertices_node
.children()
.filter(|n| n.has_tag_name((CORE, "vertex")))
{
consume(&mut budget.vertices, MAX_TRIANGLES, "source vertex")?;
let vertex = Vec3::new(
attribute(node, "x")?.parse()?,
attribute(node, "y")?.parse()?,
attribute(node, "z")?.parse()?,
) * unit;
if !vertex.is_finite() {
return Err("3MF contains non-finite vertices".into());
}
vertices.push(vertex);
}
let triangles = core_child(mesh, "triangles")?.ok_or("3MF mesh has no triangles")?;
for node in triangles
.children()
.filter(|n| n.has_tag_name((CORE, "triangle")))
{
consume(&mut budget.triangles, MAX_TRIANGLES, "source triangle")?;
let indices = [id(node, "v1")?, id(node, "v2")?, id(node, "v3")?];
if indices.iter().any(|&i| i as usize >= vertices.len()) {
return Err("3MF triangle references a missing vertex".into());
}
let pid: Option<u32> = node
.attribute("pid")
.map(str::parse)
.transpose()?
.or(property_group);
let triangle_color = if let Some(pid) = pid {
let p1: usize = node
.attribute("p1")
.map(str::parse)
.transpose()?
.unwrap_or(object_pindex);
let p2: usize = node
.attribute("p2")
.map(str::parse)
.transpose()?
.unwrap_or(p1);
let p3: usize = node
.attribute("p3")
.map(str::parse)
.transpose()?
.unwrap_or(p1);
if let Some(palette) = colors.get(&pid) {
if [p1, p2, p3].iter().any(|&i| i >= palette.len()) {
return Err("3MF color index is out of range".into());
}
Some(std::array::from_fn(|channel| {
((u32::from(palette[p1][channel])
+ u32::from(palette[p2][channel])
+ u32::from(palette[p3][channel]))
/ 3) as u8
}))
} else if let Some(group) = groups.get(&pid) {
if [p1, p2, p3]
.iter()
.any(|&index| index >= group.coordinates.len())
{
return Err("3MF texture coordinate index is out of range".into());
}
object.textures.insert(
object.triangles.len(),
TextureMapping {
texture: group.texture,
coordinates: [p1, p2, p3].map(|index| group.coordinates[index]),
},
);
None
} else {
return Err("3MF color or texture property group is missing".into());
}
} else {
None
};
object.triangles.push(Triangle {
vertices: indices.map(|i| vertices[i as usize]),
color: triangle_color,
});
}
}
if let Some(components) = components {
for component in components
.children()
.filter(|n| n.has_tag_name((CORE, "component")))
{
object
.components
.push(reference(component, root, unit, budget)?);
}
}
objects.insert(object_id, object);
}
let mut build = Vec::new();
if root {
let element = core_child(model, "build")?.ok_or("3MF root model has no build")?;
for item in element
.children()
.filter(|n| n.has_tag_name((CORE, "item")))
{
build.push(reference(item, true, unit, budget)?);
}
}
Ok(ModelPart {
objects,
build,
textures,
})
}
fn render_parts(root: &str, parts: &HashMap<String, ModelPart>) -> Result<Scene> {
let mut heights = HashMap::new();
let mut active = HashSet::new();
for (name, part) in parts {
for &id in part.objects.keys() {
validate_object(name, id, parts, &mut heights, &mut active)?;
}
}
let part = parts.get(root).ok_or("3MF root part is missing")?;
let mut scene = Scene::default();
let mut instances = 0;
for item in &part.build {
instantiate(
item.part.as_deref().unwrap_or(root),
item.object,
item.transform,
parts,
&mut scene,
&mut instances,
)?;
}
scene.validate()?;
Ok(scene)
}
fn validate_object(
part: &str,
id: u32,
parts: &HashMap<String, ModelPart>,
heights: &mut HashMap<(String, u32), usize>,
active: &mut HashSet<(String, u32)>,
) -> Result<usize> {
let key = (part.to_owned(), id);
if let Some(height) = heights.get(&key) {
return Ok(*height);
}
if active.len() >= MAX_DEPTH || !active.insert(key.clone()) {
return Err("3MF components are cyclic or too deep".into());
}
let model = parts
.get(part)
.ok_or("3MF references a missing model part")?;
let object = model
.objects
.get(&id)
.ok_or("3MF references a missing object in its model part")?;
let mut height = 1;
for child in &object.components {
height = height.max(
1 + validate_object(
child.part.as_deref().unwrap_or(part),
child.object,
parts,
heights,
active,
)?,
);
}
if height > MAX_DEPTH {
return Err("3MF components exceed the nesting limit".into());
}
active.remove(&key);
heights.insert(key, height);
Ok(height)
}
fn instantiate(
part: &str,
id: u32,
transform: Transform,
parts: &HashMap<String, ModelPart>,
scene: &mut Scene,
instances: &mut usize,
) -> Result<()> {
consume(instances, MAX_REFERENCES, "instantiated object")?;
if transform.0.iter().flatten().any(|value| !value.is_finite()) {
return Err("3MF composed transform exceeds numeric range".into());
}
let model = parts
.get(part)
.ok_or("3MF references a missing model part")?;
let object = model
.objects
.get(&id)
.ok_or("3MF references a missing object in its model part")?;
if scene.triangles.len().saturating_add(object.triangles.len()) > MAX_TRIANGLES {
return Err("3MF instantiated scene exceeds triangle limit".into());
}
let offset = scene.triangles.len();
scene
.triangles
.extend(object.triangles.iter().map(|triangle| Triangle {
vertices: triangle.vertices.map(|v| transform.apply(v)),
color: triangle.color,
}));
for (&index, mapping) in &object.textures {
let texture = model
.textures
.get(&mapping.texture)
.ok_or("3MF triangle references a missing texture")?;
let image = texture
.image
.as_ref()
.ok_or("3MF texture image has not been loaded")?;
scene.appearances.insert(
offset + index,
TriangleAppearance {
texcoords: Some(mapping.coordinates),
texture: Some(Texture {
image: Arc::clone(image),
wrap_s: texture.wrap_s,
wrap_t: texture.wrap_t,
filter: texture.filter,
min_filter: texture.filter,
interpolation_space: TextureInterpolationSpace::Srgb,
}),
..TriangleAppearance::default()
},
);
}
for child in &object.components {
instantiate(
child.part.as_deref().unwrap_or(part),
child.object,
transform.then(child.transform),
parts,
scene,
instances,
)?;
}
Ok(())
}
#[cfg(test)]
fn parse(xml: &str) -> Result<Scene> {
let part = parse_part(xml, true, &mut Budget::default())?;
render_parts("root.model", &HashMap::from([("root.model".into(), part)]))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
use std::io::Write;
fn model(resources: &str, build: &str) -> String {
format!(
"<model xmlns=\"{CORE}\"><resources>{resources}</resources><build>{build}</build></model>"
)
}
const MESH: &str = "<object id=\"1\"><mesh><vertices><vertex x=\"0\" y=\"0\" z=\"0\"/><vertex x=\"1\" y=\"0\" z=\"0\"/><vertex x=\"0\" y=\"1\" z=\"0\"/></vertices><triangles><triangle v1=\"0\" v2=\"1\" v3=\"2\"/></triangles></mesh></object>";
#[test]
fn build_and_component_transforms_compose() {
let xml = model(
&format!(
"{MESH}<object id=\"2\"><components><component objectid=\"1\" transform=\"1 0 0 0 1 0 0 0 1 10 0 0\"/></components></object>"
),
"<item objectid=\"2\" transform=\"2 0 0 0 2 0 0 0 2 0 3 0\"/>",
);
let scene = parse(&xml).unwrap();
assert_eq!(scene.triangles[0].vertices[0], Vec3::new(20., 3., 0.));
assert_eq!(scene.triangles[0].vertices[1], Vec3::new(22., 3., 0.));
}
#[test]
fn cycles_missing_objects_and_bad_indices_fail() {
assert!(
parse(&model(
"<object id=\"1\"><components><component objectid=\"1\"/></components></object>",
"<item objectid=\"1\"/>"
))
.is_err()
);
assert!(parse(&model(MESH, "<item objectid=\"2\"/>")).is_err());
assert!(
parse(&model(
&MESH.replace("v3=\"2\"", "v3=\"3\""),
"<item objectid=\"1\"/>"
))
.is_err()
);
}
#[test]
fn package_paths_decode_and_validate() {
assert_eq!(package_path("/3D/a%20b.model").unwrap(), "3d/a b.model");
assert_eq!(package_path("/3D/100%25.model").unwrap(), "3d/100%.model");
assert_eq!(
relationship_target("../parts/a.model", "3d").unwrap(),
"parts/a.model"
);
for path in [
"../a.model",
"file:/tmp/a.model",
"//3D/a.model",
"/3D/%2e%2e/a.model",
"/3D/a%2fb.model/../c.model",
"/3D/a.model?x",
"/3D/a%00.model",
] {
assert!(package_path(path).is_err(), "accepted {path}");
}
assert!(relationship_target("../../a.model", "3d").is_err());
}
fn production_model(unit: &str, resources: &str, build: Option<&str>) -> String {
let build = build
.map(|items| format!("<build>{items}</build>"))
.unwrap_or_default();
format!(
"<model xmlns=\"{CORE}\" xmlns:p=\"{PRODUCTION}\" requiredextensions=\"p\" unit=\"{unit}\"><resources>{resources}</resources>{build}</model>"
)
}
fn relationships(targets: &[&str]) -> String {
let relations = targets
.iter()
.enumerate()
.fold(String::new(), |mut text, (i, target)| {
let _ = write!(
text,
"<Relationship Type=\"{MODEL_RELATIONSHIP}\" Target=\"{target}\" Id=\"r{i}\"/>"
);
text
});
format!("<Relationships xmlns=\"{RELATIONSHIPS}\">{relations}</Relationships>")
}
fn with_package<T>(
entries: &[(&str, &str)],
read: impl FnOnce(&Path) -> Result<T>,
) -> Result<T> {
let bytes: Vec<_> = entries
.iter()
.map(|&(name, contents)| (name, contents.as_bytes()))
.collect();
with_byte_package(&bytes, read)
}
fn with_byte_package<T>(
entries: &[(&str, &[u8])],
read: impl FnOnce(&Path) -> Result<T>,
) -> Result<T> {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
struct Temp(std::path::PathBuf);
impl Drop for Temp {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let path = Temp(std::env::temp_dir().join(format!(
"catwalk-3mf-test-{}-{}.3mf",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)));
let mut writer = zip::ZipWriter::new(std::fs::File::create(&path.0)?);
for &(name, contents) in entries {
writer.start_file(
name,
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated),
)?;
writer.write_all(contents)?;
}
writer.finish()?;
read(&path.0)
}
fn package_scene(entries: &[(&str, &str)]) -> Result<Scene> {
with_package(entries, load)
}
fn split_scene(root: &str, child: &str) -> Result<Scene> {
package_scene(&[
("_rels/.rels", &relationships(&["/3D/main.model"])),
(
"3D/_rels/main.model.rels",
&relationships(&["Objects/part%20one.model"]),
),
("3D/main.model", root),
("3D/Objects/part one.model", child),
])
}
fn close(actual: Vec3, expected: Vec3) {
assert!(
(actual - expected).length() < 1e-9,
"actual={actual:?}, expected={expected:?}"
);
}
#[test]
fn multipart_ids_colors_units_and_nested_transforms_are_scoped() {
let root_mesh = MESH.replace("id=\"1\"", "id=\"1\" pid=\"3\"");
let child_mesh = root_mesh.clone();
let root = production_model(
"centimeter",
&format!(
"<basematerials id=\"3\"><base displaycolor=\"#FF0000\"/></basematerials>{root_mesh}<object id=\"2\"><components><component p:path=\"/3D/Objects/part%20one.model\" objectid=\"2\" transform=\"2 0 0 0 2 0 0 0 2 3 0 0\"/></components></object>"
),
Some(
"<item objectid=\"1\"/><item objectid=\"2\" transform=\"0 1 0 -1 0 0 0 0 1 0 4 0\"/>",
),
);
let child = production_model(
"inch",
&format!(
"<basematerials id=\"3\"><base displaycolor=\"#0000FF\"/></basematerials>{child_mesh}<object id=\"2\"><components><component objectid=\"1\" transform=\"1 0 0 0 1 0 0 0 1 1 0 0\"/></components></object>"
),
None,
);
let scene = split_scene(&root, &child).unwrap();
assert_eq!(scene.triangles.len(), 2);
assert_eq!(scene.triangles[0].color, Some([255, 0, 0]));
assert_eq!(scene.triangles[1].color, Some([0, 0, 255]));
close(scene.triangles[0].vertices[1], Vec3::new(10., 0., 0.));
close(scene.triangles[1].vertices[0], Vec3::new(0., 120.8, 0.));
close(scene.triangles[1].vertices[1], Vec3::new(0., 171.6, 0.));
close(scene.triangles[1].vertices[2], Vec3::new(-50.8, 120.8, 0.));
let v = scene.triangles[1].vertices;
assert!(((v[1] - v[0]).cross(v[2] - v[0]).length() / 2. - 1290.32).abs() < 1e-8);
}
#[test]
fn direct_build_path_accepts_alternate_prefix_and_ignores_child_build() {
let root = production_model(
"millimeter",
"",
Some("<item p:path=\"/3D/Objects/part%20one.model\" objectid=\"1\"/>"),
)
.replace("xmlns:p=", "xmlns:q=")
.replace("requiredextensions=\"p\"", "requiredextensions=\"q\"")
.replace("p:path=", "q:path=");
let child = production_model(
"meter",
MESH,
Some("<item objectid=\"999\" p:path=\"/missing.model\" transform=\"bad\"/>"),
);
let scene = split_scene(&root, &child).unwrap();
close(scene.triangles[0].vertices[1], Vec3::new(1000., 0., 0.));
assert_eq!(scene.triangles.len(), 1);
}
#[test]
fn production_namespace_and_required_extensions_remain_strict() {
let root = production_model(
"millimeter",
"",
Some("<item p:path=\"/3D/Objects/part%20one.model\" objectid=\"1\"/>"),
);
let child = production_model("millimeter", MESH, None);
assert!(split_scene(&root.replace(PRODUCTION, "urn:unknown"), &child).is_err());
assert!(split_scene(&root.replace("p:path=", "path="), &child).is_err());
assert!(
split_scene(
&root.replace(
"/3D/Objects/part%20one.model",
"3D/Objects/part%20one.model"
),
&child
)
.is_err()
);
assert!(
split_scene(
&root.replace(
"requiredextensions=\"p\"",
"xmlns:x=\"urn:unknown\" requiredextensions=\"p x\""
),
&child
)
.is_err()
);
assert!(split_scene(&root, &child.replace(PRODUCTION, "urn:unknown")).is_err());
}
#[test]
fn missing_parts_relationships_and_object_ids_fail() {
let root = production_model(
"millimeter",
MESH,
Some("<item p:path=\"/3D/Objects/part%20one.model\" objectid=\"1\"/>"),
);
let child = production_model("millimeter", &MESH.replace("id=\"1\"", "id=\"2\""), None);
let error = split_scene(&root, &child).unwrap_err().to_string();
assert!(
error.contains("missing object in its model part"),
"{error}"
);
assert!(
package_scene(&[
("_rels/.rels", &relationships(&["/3D/main.model"])),
("3D/main.model", &root)
])
.is_err()
);
assert!(
package_scene(&[
("_rels/.rels", &relationships(&["/3D/main.model"])),
("3D/main.model", &root),
(
"3D/_rels/main.model.rels",
&relationships(&["/3D/Objects/part%20one.model"])
)
])
.is_err()
);
assert!(
package_scene(&[
("_rels/.rels", &relationships(&["/3D/main.model"])),
("3D/main.model", &root),
(
"3D/_rels/main.model.rels",
&relationships(&["/3D/other.model"])
),
("3D/Objects/part one.model", &child)
])
.is_err()
);
}
#[test]
fn duplicate_package_parts_resources_and_relationships_fail() {
let root = model(MESH, "<item objectid=\"1\"/>");
assert!(
package_scene(&[("3D/3dmodel.model", &root), ("3d/3DMODEL.model", &root)]).is_err()
);
assert!(parse(&model(&format!("{MESH}{MESH}"), "<item objectid=\"1\"/>")).is_err());
assert!(
parse(&model(
&MESH.replace("id=\"1\"", "id=\"0\""),
"<item objectid=\"0\"/>"
))
.is_err()
);
assert!(model_relationships(&relationships(&["/3D/a.model", "/3d/A.model"]), "").is_err());
assert!(
model_relationships(
&relationships(&["/3D/a.model", "/3D/b.model"]).replace("Id=\"r1\"", "Id=\"r0\""),
""
)
.is_err()
);
assert!(
model_relationships(
&relationships(&["/3D/a.model"]).replace("Id=", "TargetMode=\"External\" Id="),
""
)
.is_err()
);
}
#[test]
fn child_external_components_and_local_cycles_fail() {
let root = production_model(
"millimeter",
"",
Some("<item p:path=\"/3D/Objects/part%20one.model\" objectid=\"1\"/>"),
);
let child = production_model(
"millimeter",
"<object id=\"1\"><components><component p:path=\"/3D/main.model\" objectid=\"1\"/></components></object>",
None,
);
assert!(split_scene(&root, &child).is_err());
let child = production_model(
"millimeter",
"<object id=\"1\"><components><component objectid=\"2\"/></components></object><object id=\"2\"><components><component objectid=\"1\"/></components></object>",
None,
);
assert!(split_scene(&root, &child).is_err());
}
#[test]
fn aggregate_source_and_decompressed_budgets_are_enforced() {
let xml = model(MESH, "<item objectid=\"1\"/>");
for mut budget in [
Budget {
vertices: MAX_TRIANGLES,
..Budget::default()
},
Budget {
triangles: MAX_TRIANGLES,
..Budget::default()
},
Budget {
resources: MAX_RESOURCES,
..Budget::default()
},
Budget {
references: MAX_REFERENCES,
..Budget::default()
},
] {
assert!(parse_part(&xml, true, &mut budget).is_err());
}
let error = with_package(&[("3D/3dmodel.model", &xml)], |path| {
let mut package = Package::open(path)?;
package.read(
"3d/3dmodel.model",
&mut Budget {
bytes: MAX_INPUT_BYTES - xml.len() as u64 + 1,
..Budget::default()
},
)
})
.unwrap_err()
.to_string();
assert!(error.contains("aggregate 512 MiB"), "{error}");
assert!(parse(&xml.replace("x=\"1\"", "x=\"inf\"")).is_err());
assert!(parse(&xml.replace("<model ", "<model unit=\"unsupported\" ")).is_err());
assert!(
parse(&model(
&MESH.replace("</object>", "<components/></object>"),
"<item objectid=\"1\"/>"
))
.is_err()
);
}
#[test]
fn texture_cache_preserves_mime_and_counts_each_distinct_image_once() {
let mut png = Vec::new();
crate::render::write_png(
&RgbaImage::from_pixel(1, 1, image::Rgba([31, 47, 63, 79])),
&mut png,
)
.unwrap();
with_byte_package(&[("first.png", &png), ("second.png", &png)], |path| {
let mut package = Package::open(path)?;
let mut budget = Budget {
pixels: MAX_TOTAL_TEXTURE_PIXELS - 1,
..Budget::default()
};
let first = package.texture("first.png", ImageFormat::Png, &mut budget)?;
assert_eq!(budget.pixels, MAX_TOTAL_TEXTURE_PIXELS);
let consumed = budget.bytes;
let same = package.texture("first.png", ImageFormat::Png, &mut budget)?;
assert!(Arc::ptr_eq(&first, &same));
assert_eq!(budget.bytes, consumed);
assert_eq!(budget.pixels, MAX_TOTAL_TEXTURE_PIXELS);
assert!(
package
.texture("first.png", ImageFormat::Jpeg, &mut budget)
.unwrap_err()
.to_string()
.contains("content type")
);
assert_eq!(budget.bytes, consumed);
assert!(
package
.texture("second.png", ImageFormat::Png, &mut budget)
.unwrap_err()
.to_string()
.contains("aggregate pixel budget")
);
Ok(())
})
.unwrap();
}
#[test]
fn encoded_texture_and_coordinate_counts_obey_aggregate_limits() {
let mut png = Vec::new();
crate::render::write_png(&RgbaImage::new(1, 1), &mut png).unwrap();
with_byte_package(&[("image.png", &png)], |path| {
let mut package = Package::open(path)?;
let error = package
.texture(
"image.png",
ImageFormat::Png,
&mut Budget {
bytes: MAX_INPUT_BYTES,
..Budget::default()
},
)
.unwrap_err()
.to_string();
assert!(error.contains("aggregate 512 MiB"));
assert!(package.images.is_empty());
Ok(())
})
.unwrap();
let xml = format!(
"<m:texture2dgroup xmlns:m=\"{MATERIAL}\" id=\"1\" texid=\"2\"><m:tex2coord u=\"0\" v=\"0\"/></m:texture2dgroup>"
);
let document = roxmltree::Document::parse(&xml).unwrap();
let error = texture_group(
document.root_element(),
&mut Budget {
texcoords: MAX_TRIANGLES,
..Budget::default()
},
)
.err()
.unwrap()
.to_string();
assert!(error.contains("texture coordinate"));
}
#[test]
fn production_model_part_count_is_bounded() {
let build = (0..MAX_PARTS).fold(String::new(), |mut text, n| {
let _ = write!(text, "<item p:path=\"/3D/{n}.model\" objectid=\"1\"/>");
text
});
let root = production_model("millimeter", "", Some(&build));
let error = package_scene(&[("3D/3dmodel.model", &root)])
.unwrap_err()
.to_string();
assert!(error.contains("model-part limit"), "{error}");
}
#[test]
fn deep_and_exponentially_instanced_components_are_bounded() {
let mut resources = MESH.to_owned();
for id in 2..=MAX_DEPTH + 1 {
let _ = write!(
resources,
"<object id=\"{id}\"><components><component objectid=\"{}\"/></components></object>",
id - 1
);
}
assert!(
parse(&model(
&resources,
&format!("<item objectid=\"{}\"/>", MAX_DEPTH + 1)
))
.is_err()
);
let mut resources = "<object id=\"1\"><components/></object>".to_owned();
for id in 2..=21 {
let _ = write!(
resources,
"<object id=\"{id}\"><components><component objectid=\"{}\"/><component objectid=\"{}\"/></components></object>",
id - 1,
id - 1
);
}
let error = parse(&model(&resources, "<item objectid=\"21\"/>"))
.unwrap_err()
.to_string();
assert!(error.contains("instantiated object"), "{error}");
}
fn zip_bytes(zip64_comment: Option<&[u8]>) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
writer.set_raw_zip64_comment(zip64_comment.map(Into::into));
writer.set_comment("a package comment containing PK\u{5}\u{6}");
writer
.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default())
.unwrap();
writer
.write_all(model(MESH, "<item objectid=\"1\"/>").as_bytes())
.unwrap();
writer.finish().unwrap().into_inner()
}
fn eocd_offset(bytes: &[u8]) -> usize {
bytes
.windows(22)
.enumerate()
.rev()
.find(|(i, record)| {
record.starts_with(b"PK\x05\x06")
&& i + 22 + usize::from(zip_u16(record, 20).unwrap()) == bytes.len()
})
.unwrap()
.0
}
#[test]
#[allow(
clippy::cast_possible_truncation,
reason = "Small authored test payload lengths fit their native field widths; preserve the exact binary fixture encoding."
)]
fn raw_zip_duplicates_are_rejected_before_library_coalescing() {
let mut bytes = zip_bytes(None);
let eocd = eocd_offset(&bytes);
let directory = zip_u32(&bytes, eocd + 16).unwrap() as usize;
let record = bytes[directory..eocd].to_vec();
bytes.splice(eocd..eocd, record.iter().copied());
let eocd = eocd + record.len();
bytes[eocd + 8..eocd + 12].copy_from_slice(&[2, 0, 2, 0]);
bytes[eocd + 12..eocd + 16].copy_from_slice(&(2 * record.len() as u32).to_le_bytes());
assert_eq!(
zip::ZipArchive::new(std::io::Cursor::new(&bytes))
.unwrap()
.len(),
1
);
let error = validate_zip_directory(&mut std::io::Cursor::new(&bytes), bytes.len() as u64)
.unwrap_err()
.to_string();
assert!(error.contains("duplicate 3MF package part"), "{error}");
}
#[test]
fn zip64_directory_handles_extensions_comments_and_archive_prefixes() {
for comment in [Vec::new(), b"ZIP64 metadata".to_vec(), vec![0; 70_000]] {
let bytes = zip_bytes(Some(&comment));
assert_eq!(
validate_zip_directory(&mut std::io::Cursor::new(&bytes), bytes.len() as u64)
.unwrap(),
1
);
assert_eq!(
zip::ZipArchive::new(std::io::Cursor::new(&bytes))
.unwrap()
.len(),
1
);
let mut prefixed = b"an archive prefix with no filesystem effect".to_vec();
prefixed.extend_from_slice(&bytes);
assert_eq!(
validate_zip_directory(&mut std::io::Cursor::new(&prefixed), prefixed.len() as u64)
.unwrap(),
1
);
}
}
#[test]
#[allow(
clippy::cast_possible_truncation,
reason = "Small authored test payload lengths fit their native field widths; preserve the exact binary fixture encoding."
)]
fn raw_zip_directory_counts_and_extents_are_bounded() {
let original = zip_bytes(None);
for length in 0..original.len() {
assert!(
validate_zip_directory(
&mut std::io::Cursor::new(&original[..length]),
length as u64
)
.is_err()
);
}
for (offset, value) in [
(8, [0, 0, 0, 0]),
(12, u32::MAX.to_le_bytes()),
(16, u32::MAX.to_le_bytes()),
] {
let mut bytes = original.clone();
let eocd = eocd_offset(&bytes);
bytes[eocd + offset..eocd + offset + 4].copy_from_slice(&value);
assert!(
validate_zip_directory(&mut std::io::Cursor::new(&bytes), bytes.len() as u64)
.is_err()
);
}
let mut bytes = zip_bytes(Some(&[]));
let eocd = eocd_offset(&bytes);
let locator = eocd - 20;
let zip64 = zip_u64(&bytes, locator + 8).unwrap() as usize;
bytes[zip64 + 24..zip64 + 40].copy_from_slice(
&[
(MAX_RESOURCES as u64 + 1).to_le_bytes(),
(MAX_RESOURCES as u64 + 1).to_le_bytes(),
]
.concat(),
);
bytes[eocd + 8..eocd + 12].fill(255);
let error = validate_zip_directory(&mut std::io::Cursor::new(&bytes), bytes.len() as u64)
.unwrap_err()
.to_string();
assert!(error.contains("too many archive entries"), "{error}");
}
}