From 2144a8c48c30aa9deb3ccc3ef99827a6a0117b52 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 17:35:28 -0500 Subject: [PATCH] craft-mesh: FBX reader (6.x and 7.x, binary and ASCII) (#906) * craft-mesh: FBX 7.x reader from EffectCraft (`fbx`) EffectCraft's FBX reader (crates/model/src/fbx.rs: binary with 32- and 64-bit record offsets and zlib arrays, ASCII, geometry layer elements, the Model transform chain, Lambert/Phong materials, textures) onto the crate's Scene. The reader was written by vangel76 (EffectCraft #736); its memory budget and shared instanced meshes by DWAA1660 (EffectCraft #1234). It includes the fixes of the open EffectCraft PR #2955: a binary child record must end within its parent, a failed array inflate reports only its status, textures that end at one image share it and its bytes are charged to the budget, and material slots and connections are found through maps. What differs from the app's copy, because the model here is neutral: polygons stay whole and layer elements stay indexed pools (no vertex unification, no computed normals or tangents); images are bytes, not decoded pixels; the axis system is not applied (Document::axes_to_y_up gives it); the memory budget is Limits::max_data_bytes; a file without meshes is a scene, not an error. base64_decode moves from the gltf module to the crate root so both formats share it (gltf re-exports it). * craft-mesh: read FBX 6.x FBX 6.1 files differ from 7.x in how the scene is laid out, not in the record syntax: a Model holds its own Vertices and layer elements, properties are Properties60/Property (no label field), Connect lines name objects by Class::Name instead of by id, and GlobalSettings sits among the objects. The reader now takes all four, and NormalIndex beside NormalsIndex, so 6.x geometry, transforms, the hierarchy and materials are read. 6.x textures (connected to Models) are not. Ported from storytold/deckcraft#1380 (crates/mesh/src/fbx), whose reader took 6.1 to 7.7; its mesh building, axis turn and rasteriser stay in DeckCraft. --------- Co-authored-by: Ethan Dale --- Cargo.lock | 1 + README.md | 4 +- crates/mesh/Cargo.toml | 7 +- crates/mesh/README.md | 31 +- crates/mesh/src/fbx.rs | 1157 ++++++++++++++++++++++++++++++++++ crates/mesh/src/gltf.rs | 27 +- crates/mesh/src/lib.rs | 33 +- crates/mesh/src/tests.rs | 2 + crates/mesh/src/tests/fbx.rs | 629 ++++++++++++++++++ 9 files changed, 1852 insertions(+), 39 deletions(-) create mode 100644 crates/mesh/src/fbx.rs create mode 100644 crates/mesh/src/tests/fbx.rs diff --git a/Cargo.lock b/Cargo.lock index 770da21ba..91cdb03b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1116,6 +1116,7 @@ dependencies = [ name = "craft-mesh" version = "0.1.0" dependencies = [ + "miniz_oxide 0.9.1", "serde_json", ] diff --git a/README.md b/README.md index a8ce21dce..cca0f75b3 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " geometry`), `Material` (Lambert/Phong +//! mapped onto metallic-roughness), `Texture` and `Video` (embedded or named image files), +//! joined by `Connections`. +//! - **6.x**: a `Model` holds its own mesh, properties are `Properties60`, and `Connect` lines +//! name objects by `Class::Name` instead of by id. Geometry, transforms, the hierarchy and +//! materials are read; 6.x textures (which connect to Models, not Materials) are not. +//! +//! [`read`] gives the [`Scene`]. [`read_document`] gives the record tree itself +//! ([`Document`]), for callers that need what the scene does not carry; +//! [`Document::axes_to_y_up`] and [`Document::unit_scale`] are the file's `GlobalSettings`, which +//! the scene leaves alone like every reader here: positions stay in the file's axes and units. +//! +//! A layer element is kept as a pool: each corner indexes the `Normals` and `UV` arrays as the +//! element's mapping and reference modes say, and a corner whose index is outside the array has +//! no normal or UV. UVs are flipped to v down. A `Model` is a node; Models that instance one +//! geometry with the same materials share one mesh. A material slot of a geometry is a +//! [`Part`]. A polygon of fewer than three corners, or one that names a vertex that is not +//! there, is left out and counted in a warning. +//! +//! **Images.** A `Texture` whose `Video` embeds its content gives an [`Image`] with `data`. +//! Otherwise the resolver is asked for `RelativeFilename`, then `FileName` (each with `\` as +//! `/`, as written and then by its last component), because only the file knows both names; what +//! it supplies is the image's `data`, and a texture it declines is an image with only its `uri`. +//! Textures that end at one embedded `Video` or one supplied file share one image. Every file +//! name is listed in [`Scene::external`]. The names come from the file: a resolver must not trust +//! them as paths. +//! +//! Not read: FBX 5 and older, animation stacks, skin deformers, blend shapes, cameras and +//! lights, NURBS and patches. +//! +//! [`Limits::max_data_bytes`] bounds what one file may keep in memory: its parsed values (an +//! array element counts 8 bytes whatever its type in the file), the meshes built from them and +//! the image bytes. + +use std::cell::Cell; +use std::collections::HashMap; + +use crate::{ + AlphaMode, Corner, IDENTITY, Image, Limits, Mat4, Material, Mesh, NONE, Node, Part, Resolver, Result, Scene, TextureRef, base64_decode, + count_warning, malformed, mat_mul, srgb_to_linear, too_large, +}; + +const KIND: &str = "FBX"; + +/// The binary file's magic (`Kaydara FBX Binary`, two spaces, NUL, 0x1A, 0x00). +pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; +/// Deepest record nesting accepted (real files nest well under 10 levels). +pub const MAX_DEPTH: usize = 64; +/// Largest decoded array accepted, in bytes as the file stores it. +pub const MAX_ARRAY_BYTES: usize = 1 << 30; + +/// What is left of the memory one file may keep. +struct Budget { + left: Cell, + limit: usize, +} + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget { left: Cell::new(bytes), limit: bytes } + } + + /// Take `bytes` (None: a size that overflowed) from the budget, or fail before anything that + /// large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.left.get(); + match bytes.filter(|b| *b <= left) { + Some(b) => { + self.left.set(left - b); + Ok(()) + } + None => Err(too_large(KIND, format!("{what} would take the file past its memory limit of {} bytes", self.limit))), + } + } +} + +/// Bytes the values under `records` keep. Used for ASCII files, which hold array elements as one +/// [`Prop`] each and are parsed before they can be counted (the text itself bounds them). +pub(crate) fn value_bytes(records: &[Record]) -> Option { + records.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} + +/// Bytes a mesh keeps. +pub(crate) fn mesh_bytes(m: &Mesh) -> Option { + let uvs = m.uv_sets.iter().try_fold(0usize, |a, s| a.checked_add(s.len().checked_mul(8)?))?; + let pools = m.positions.len().checked_mul(24)?.checked_add(m.normals.len().checked_mul(24)?)?.checked_add(uvs)?; + m.parts.iter().try_fold(pools, |a, p| a.checked_add(p.corners.len().checked_mul(12)?)?.checked_add(p.face_sizes.len().checked_mul(4)?)) +} + +/// One property value of a record. +#[derive(Clone, Debug, PartialEq)] +pub enum Prop { + Int(i64), + Float(f64), + Str(String), + Bytes(Vec), + Ints(Vec), + Floats(Vec), +} + +impl Prop { + pub fn as_f64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i as f64), + Prop::Float(f) => Some(*f), + _ => None, + } + } + + pub fn as_i64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i), + Prop::Float(f) if f.is_finite() => Some(*f as i64), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Prop::Str(s) => Some(s), + _ => None, + } + } +} + +/// A record of the FBX document tree (the same for binary and ASCII files). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Record { + pub name: String, + pub props: Vec, + pub children: Vec, +} + +impl Record { + pub fn child(&self, name: &str) -> Option<&Record> { + self.children.iter().find(|c| c.name == name) + } + + pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children.iter().filter(move |c| c.name == name) + } + + fn str_at(&self, i: usize) -> Option<&str> { + self.props.get(i).and_then(Prop::as_str) + } + + /// The first string property: an object's `Class::Name`. + fn first_str(&self) -> Option<&str> { + self.props.iter().find_map(Prop::as_str) + } + + /// The record's array as floats: a binary array property, an ASCII `a:` child, or the plain + /// value list. + pub fn floats(&self) -> Vec { + if let Some(a) = self.child("a") { + return a.floats(); + } + let mut out = vec![]; + for p in &self.props { + match p { + Prop::Floats(v) => out.extend_from_slice(v), + Prop::Ints(v) => out.extend(v.iter().map(|i| *i as f64)), + Prop::Int(i) => out.push(*i as f64), + Prop::Float(f) => out.push(*f), + _ => {} + } + } + out + } + + /// The record's array as integers (see [`Record::floats`]). + pub fn ints(&self) -> Vec { + if let Some(a) = self.child("a") { + return a.ints(); + } + let mut out = vec![]; + for p in &self.props { + match p { + Prop::Ints(v) => out.extend_from_slice(v), + Prop::Floats(v) => out.extend(v.iter().filter(|f| f.is_finite()).map(|f| *f as i64)), + p => out.extend(p.as_i64()), + } + } + out + } +} + +/// An FBX file as its tree of records. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + /// The file version (7400 is FBX 7.4, 6100 is FBX 6.1); 0 when an ASCII file does not say. + pub version: u32, + /// The top-level records (`FBXHeaderExtension`, `GlobalSettings`, `Objects`, `Connections`, …). + pub records: Vec, + /// What the records left of `Limits::max_data_bytes`. + left: usize, +} + +/// Is this a binary FBX file? Anything else is read as ASCII. +pub fn is_binary(bytes: &[u8]) -> bool { + bytes.starts_with(BINARY_MAGIC) +} + +/// Read an FBX file (binary or ASCII) into its records. +pub fn read_document(bytes: &[u8], limits: &Limits) -> Result { + let budget = Budget::new(limits.max_data_bytes); + let (version, records) = if is_binary(bytes) { + read_binary(bytes, &budget)? + } else { + let (version, records) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&records), "its values")?; + (version, records) + }; + Ok(Document { version, records, left: budget.left.get() }) +} + +/// Read an FBX file into a scene. `resolve` is asked for texture image files (see the module +/// documentation). +pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result { + read_document(bytes, limits)?.scene(limits, resolve) +} + +// --------------------------------------------------------------------------------------------- +// Binary + +struct Bin<'a> { + d: &'a [u8], + /// 64-bit record offsets (version 7500 and later). + wide: bool, + budget: &'a Budget, +} + +impl Bin<'_> { + fn take(&self, pos: &mut usize, n: usize) -> Result<&[u8]> { + let end = pos.checked_add(n).ok_or_else(|| malformed(KIND, "offset overflow"))?; + let s = self.d.get(*pos..end).ok_or_else(|| malformed(KIND, format!("truncated at byte {pos}")))?; + *pos = end; + Ok(s) + } + + fn arr(&self, pos: &mut usize) -> Result<[u8; N]> { + let mut a = [0u8; N]; + a.copy_from_slice(self.take(pos, N)?); + Ok(a) + } + + fn u32(&self, pos: &mut usize) -> Result { + Ok(u32::from_le_bytes(self.arr(pos)?)) + } + + fn offset(&self, pos: &mut usize) -> Result { + if self.wide { Ok(u64::from_le_bytes(self.arr(pos)?)) } else { Ok(u64::from(self.u32(pos)?)) } + } + + fn to_usize(v: u64) -> Result { + usize::try_from(v).map_err(|_| malformed(KIND, "offset out of range")) + } + + /// Records until `end` or a null record. + fn list(&self, pos: &mut usize, end: usize, depth: usize) -> Result> { + if depth > MAX_DEPTH { + return Err(too_large(KIND, "records nested too deeply")); + } + let mut out = vec![]; + while *pos < end { + match self.record(pos, end, depth)? { + Some(n) => out.push(n), + None => break, + } + } + Ok(out) + } + + /// One record ending within `limit` (the enclosing record's end); `None` for the null record + /// that closes a list. + fn record(&self, pos: &mut usize, limit: usize, depth: usize) -> Result> { + let start = *pos; + let end = Self::to_usize(self.offset(pos)?)?; + let nprops = self.offset(pos)?; + let plen = Self::to_usize(self.offset(pos)?)?; + let nlen = usize::from(self.arr::<1>(pos)?[0]); + if end == 0 && nprops == 0 && plen == 0 && nlen == 0 { + return Ok(None); + } + // A child reaching past its parent would be parsed again once the parent's end is + // restored. + if end <= start || end > limit.min(self.d.len()) { + return Err(malformed(KIND, format!("bad record end {end} at byte {start}"))); + } + let name = String::from_utf8_lossy(self.take(pos, nlen)?).into_owned(); + let pstart = *pos; + let pend = + pstart.checked_add(plen).filter(|e| *e <= end).ok_or_else(|| malformed(KIND, format!("`{name}`: property list past the record")))?; + // Every property is at least one byte, so a count above the list length is corrupt. + if nprops > plen as u64 { + return Err(malformed(KIND, format!("`{name}`: {nprops} properties in {plen} bytes"))); + } + let mut props = Vec::with_capacity(nprops as usize); + for _ in 0..nprops { + props.push(self.prop(pos, pend)?); + } + *pos = pend; + let children = if *pos < end { self.list(pos, end, depth + 1)? } else { vec![] }; + *pos = end; + Ok(Some(Record { name, props, children })) + } + + fn prop(&self, pos: &mut usize, pend: usize) -> Result { + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| malformed(KIND, "truncated property"))?, wide: self.wide, budget: self.budget }; + let t = sub.arr::<1>(pos)?[0]; + Ok(match t { + b'Y' => Prop::Int(i64::from(i16::from_le_bytes(sub.arr(pos)?))), + b'C' => Prop::Int(i64::from(sub.arr::<1>(pos)?[0] != 0)), + b'I' => Prop::Int(i64::from(i32::from_le_bytes(sub.arr(pos)?))), + b'L' => Prop::Int(i64::from_le_bytes(sub.arr(pos)?)), + b'F' => Prop::Float(f64::from(f32::from_le_bytes(sub.arr(pos)?))), + b'D' => Prop::Float(f64::from_le_bytes(sub.arr(pos)?)), + b'S' => { + let n = sub.u32(pos)? as usize; + Prop::Str(object_name(sub.take(pos, n)?)) + } + b'R' => { + let n = sub.u32(pos)? as usize; + Prop::Bytes(sub.take(pos, n)?.to_vec()) + } + b'f' | b'd' | b'l' | b'i' | b'b' => { + let len = sub.u32(pos)? as usize; + let enc = sub.u32(pos)?; + let clen = sub.u32(pos)? as usize; + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + let want = len.checked_mul(size).filter(|n| *n <= MAX_ARRAY_BYTES).ok_or_else(|| too_large(KIND, "array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the bytes in the file. + sub.budget.take(len.checked_mul(8), "an array")?; + let data = sub.take(pos, clen)?; + let inflated; + let raw = match enc { + 0 => data, + 1 => { + // The status only: the error's Debug form prints what was decompressed. + inflated = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, want) + .map_err(|e| malformed(KIND, format!("array zlib: {:?}", e.status)))?; + inflated.as_slice() + } + e => return Err(malformed(KIND, format!("unknown array encoding {e}"))), + }; + let raw = raw.get(..want).ok_or_else(|| malformed(KIND, "array shorter than its length"))?; + let (c4, c8) = (raw.as_chunks::<4>().0, raw.as_chunks::<8>().0); + match t { + b'f' => Prop::Floats(c4.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), + b'd' => Prop::Floats(c8.iter().map(|c| f64::from_le_bytes(*c)).collect()), + b'l' => Prop::Ints(c8.iter().map(|c| i64::from_le_bytes(*c)).collect()), + b'i' => Prop::Ints(c4.iter().map(|c| i64::from(i32::from_le_bytes(*c))).collect()), + _ => Prop::Ints(raw.iter().map(|b| i64::from(*b != 0)).collect()), + } + } + t => return Err(malformed(KIND, format!("unknown property type 0x{t:02x}"))), + }) + } +} + +/// Binary object names are `Name\0\x01Class`; ASCII writes `Class::Name`. +fn object_name(b: &[u8]) -> String { + let s = String::from_utf8_lossy(b); + match s.split_once("\0\x01") { + Some((name, class)) => format!("{class}::{name}"), + None => s.into_owned(), + } +} + +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + let mut pos = BINARY_MAGIC.len(); + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; + let mut records = vec![]; + while pos < bytes.len() { + match bin.record(&mut pos, bytes.len(), 0) { + Ok(Some(n)) => records.push(n), + Ok(None) => break, + // A damaged tail (footer, padding) after complete sections is ignored. + Err(crate::Error::Malformed(_)) if records.iter().any(|n| n.name == "Objects") => break, + Err(e) => return Err(e), + } + } + Ok((version, records)) +} + +// --------------------------------------------------------------------------------------------- +// ASCII + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Key(String), + Val(Prop), + /// `*N` array length (the values follow in an `a:` child). + Count, + Comma, + Open, + Close, +} + +fn tokenize(text: &str) -> Vec { + let mut out = vec![]; + let mut it = text.char_indices().peekable(); + while let Some(&(i, c)) = it.peek() { + match c { + c if c.is_whitespace() => { + it.next(); + } + ';' => { + for (_, c) in it.by_ref() { + if c == '\n' { + break; + } + } + } + ',' => { + it.next(); + out.push(Tok::Comma); + } + '{' => { + it.next(); + out.push(Tok::Open); + } + '}' => { + it.next(); + out.push(Tok::Close); + } + '"' => { + it.next(); + let start = i + 1; + let mut end = text.len(); + for (j, c) in it.by_ref() { + if c == '"' { + end = j; + break; + } + } + out.push(Tok::Val(Prop::Str(text.get(start..end).unwrap_or("").replace(""", "\"")))); + } + _ => { + let start = i; + let mut end = text.len(); + while let Some(&(j, c)) = it.peek() { + if c.is_whitespace() || matches!(c, ',' | '{' | '}' | '"' | ';' | ':') { + end = j; + break; + } + it.next(); + } + let word = text.get(start..end).unwrap_or(""); + if it.peek().is_some_and(|(_, c)| *c == ':') { + it.next(); + out.push(Tok::Key(word.to_string())); + } else if word.starts_with('*') { + out.push(Tok::Count); + } else if let Ok(i) = word.parse::() { + out.push(Tok::Val(Prop::Int(i))); + } else if let Ok(f) = word.parse::() { + out.push(Tok::Val(Prop::Float(f))); + } else if !word.is_empty() { + out.push(Tok::Val(Prop::Str(word.to_string()))); + } else { + // A stray `:` with no key. + it.next(); + } + } + } + } + out +} + +fn ascii_list(toks: &[Tok], i: &mut usize, depth: usize) -> Result> { + if depth > MAX_DEPTH { + return Err(too_large(KIND, "records nested too deeply")); + } + let mut out = vec![]; + while let Some(t) = toks.get(*i) { + *i += 1; + match t { + Tok::Close if depth > 0 => return Ok(out), + Tok::Key(name) => { + let mut record = Record { name: name.clone(), ..Record::default() }; + loop { + match toks.get(*i) { + Some(Tok::Val(v)) => record.props.push(v.clone()), + Some(Tok::Count | Tok::Comma) => {} + _ => break, + } + *i += 1; + } + if toks.get(*i) == Some(&Tok::Open) { + *i += 1; + record.children = ascii_list(toks, i, depth + 1)?; + } + out.push(record); + } + _ => {} + } + } + Ok(out) +} + +fn read_ascii(text: &str) -> Result<(u32, Vec)> { + let toks = tokenize(text); + let mut i = 0; + let records = ascii_list(&toks, &mut i, 0)?; + let version = records + .iter() + .find(|n| n.name == "FBXHeaderExtension") + .and_then(|h| h.child("FBXVersion")) + .and_then(|v| v.props.first()) + .and_then(Prop::as_i64) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(0); + Ok((version, records)) +} + +// --------------------------------------------------------------------------------------------- +// Scene + +/// An object's properties by name (`Properties70`: `P: name, type, label, flags, values…`; 6.x +/// `Properties60`: `Property: name, type, flags, values…`). +struct Props<'a>(HashMap<&'a str, &'a Record>); + +impl<'a> Props<'a> { + fn of(record: &'a Record) -> Props<'a> { + let mut m = HashMap::new(); + for (block, item) in [("Properties70", "P"), ("Properties60", "Property")] { + for e in record.children_named(block).flat_map(|p| p.children_named(item)) { + if let Some(name) = e.str_at(0) { + m.entry(name).or_insert(e); + } + } + } + Props(m) + } + + /// The numbers after the name: the strings between (type, label, flags) are not numbers. + fn values(&self, name: &str) -> Vec { + self.0.get(name).map(|e| e.props.iter().skip(1).filter_map(Prop::as_f64).collect()).unwrap_or_default() + } + + fn f(&self, name: &str) -> Option { + self.values(name).first().copied().filter(|v| v.is_finite()) + } + + fn v3(&self, name: &str, default: [f64; 3]) -> [f64; 3] { + match self.values(name).as_slice() { + [x, y, z, ..] if x.is_finite() && y.is_finite() && z.is_finite() => [*x, *y, *z], + _ => default, + } + } +} + +fn translate(t: [f64; 3]) -> Mat4 { + let mut m = IDENTITY; + m[12] = t[0]; + m[13] = t[1]; + m[14] = t[2]; + m +} + +fn scale(s: [f64; 3]) -> Mat4 { + let mut m = IDENTITY; + m[0] = s[0]; + m[5] = s[1]; + m[10] = s[2]; + m +} + +/// A right-handed rotation of `deg` degrees about axis `k` (0 = X, 1 = Y, 2 = Z). +fn rotate(k: usize, deg: f64) -> Mat4 { + let (s, c) = deg.to_radians().sin_cos(); + let (a, b) = ((k + 1) % 3, (k + 2) % 3); + let mut m = IDENTITY; + m[a * 4 + a] = c; + m[b * 4 + a] = -s; + m[a * 4 + b] = s; + m[b * 4 + b] = c; + m +} + +fn transpose(m: &Mat4) -> Mat4 { + let mut t = [0.0; 16]; + for (i, v) in t.iter_mut().enumerate() { + *v = m[(i % 4) * 4 + i / 4]; + } + t +} + +/// The product of `ms`: the last applies first. +fn chain(ms: &[Mat4]) -> Mat4 { + ms.iter().fold(IDENTITY, |a, b| mat_mul(&a, b)) +} + +/// An Euler rotation (degrees) in FBX rotation order (`eEulerXYZ` = 0 rotates about X first). +fn euler(d: [f64; 3], order: i64) -> Mat4 { + let (x, y, z) = (rotate(0, d[0]), rotate(1, d[1]), rotate(2, d[2])); + match order { + 1 => chain(&[y, z, x]), + 2 => chain(&[x, z, y]), + 3 => chain(&[z, x, y]), + 4 => chain(&[y, x, z]), + 5 => chain(&[x, y, z]), + _ => chain(&[z, y, x]), + } +} + +/// A Model's local transform: `T · Roff · Rp · Rpre · R · Rpost⁻¹ · Rp⁻¹ · Soff · Sp · S · Sp⁻¹`. +fn model_local(p: &Props) -> Mat4 { + let order = p.f("RotationOrder").map_or(0, |o| o as i64); + let active = p.f("RotationActive").is_some_and(|a| a != 0.0); + let (pre, post) = + if active { (euler(p.v3("PreRotation", [0.0; 3]), 0), transpose(&euler(p.v3("PostRotation", [0.0; 3]), 0))) } else { (IDENTITY, IDENTITY) }; + let rp = p.v3("RotationPivot", [0.0; 3]); + let sp = p.v3("ScalingPivot", [0.0; 3]); + chain(&[ + translate(p.v3("Lcl Translation", [0.0; 3])), + translate(p.v3("RotationOffset", [0.0; 3])), + translate(rp), + pre, + euler(p.v3("Lcl Rotation", [0.0; 3]), order), + post, + translate(rp.map(|v| -v)), + translate(p.v3("ScalingOffset", [0.0; 3])), + translate(sp), + scale(p.v3("Lcl Scaling", [1.0; 3])), + translate(sp.map(|v| -v)), + ]) +} + +/// The geometric transform of a Model (applies to its geometry, not to its children). +fn geometric(p: &Props) -> Option { + let t = p.v3("GeometricTranslation", [0.0; 3]); + let r = p.v3("GeometricRotation", [0.0; 3]); + let s = p.v3("GeometricScaling", [1.0; 3]); + if t == [0.0; 3] && r == [0.0; 3] && s == [1.0; 3] { + return None; + } + Some(chain(&[translate(t), euler(r, 0), scale(s)])) +} + +impl Document { + /// The top-level record of this name. + pub fn top(&self, name: &str) -> Option<&Record> { + self.records.iter().find(|n| n.name == name) + } + + fn settings(&self) -> Option> { + // 6.x writers put it among the objects. + self.top("GlobalSettings").or_else(|| self.top("Objects")?.child("GlobalSettings")).map(Props::of) + } + + /// The turn from the file's axis system (`GlobalSettings`) to +Y up, +Z front, +X right. + /// The identity when the file does not say or what it says is not a rotation or mirror. + pub fn axes_to_y_up(&self) -> Mat4 { + let p = self.settings(); + let get = |name: &str, d: f64| p.as_ref().and_then(|p| p.f(name)).unwrap_or(d); + let axis = |a: f64, s: f64| -> Option<[f64; 3]> { + let mut v = [0.0; 3]; + *v.get_mut(usize::try_from(a as i64).ok()?)? = if s < 0.0 { -1.0 } else { 1.0 }; + Some(v) + }; + let (Some(right), Some(up), Some(front)) = ( + axis(get("CoordAxis", 0.0), get("CoordAxisSign", 1.0)), + axis(get("UpAxis", 1.0), get("UpAxisSign", 1.0)), + axis(get("FrontAxis", 2.0), get("FrontAxisSign", 1.0)), + ) else { + return IDENTITY; + }; + // Two axes on the same file axis: not a rotation. + if !(0..3).all(|k| right[k] != 0.0 || up[k] != 0.0 || front[k] != 0.0) { + return IDENTITY; + } + let mut m = IDENTITY; + for (r, row) in [right, up, front].iter().enumerate() { + for (c, v) in row.iter().enumerate() { + m[c * 4 + r] = *v; + } + } + m + } + + /// Centimetres per file unit (`GlobalSettings` `UnitScaleFactor`), when the file says. + pub fn unit_scale(&self) -> Option { + self.settings()?.f("UnitScaleFactor").filter(|s| *s > 0.0) + } + + /// The scene the records describe. `resolve` is asked for texture image files (see the + /// module documentation). + pub fn scene(&self, limits: &Limits, resolve: Resolver) -> Result { + if self.version != 0 && self.version < 6000 { + return Err(crate::Error::Unsupported(format!( + "FBX {}.{} (only FBX 6.x and 7.x files are read)", + self.version / 1000, + self.version % 1000 / 100 + ))); + } + let objects = self.top("Objects").ok_or_else(|| malformed(KIND, "no Objects section"))?; + let mut b = Build { + limits, + budget: Budget { left: Cell::new(self.left), limit: limits.max_data_bytes }, + resolve, + by_key: objects.children.iter().filter_map(|o| Some((key(o.props.first())?, o))).collect(), + children: HashMap::new(), + scene: Scene::default(), + material_of: HashMap::new(), + texture_of: HashMap::new(), + image_of: HashMap::new(), + triangles: 0, + skipped: 0, + }; + // Connections: (child, parent, property) in file order, and the same by parent, so each + // object finds its children without scanning them all. + let mut links: Vec<(Key, Key)> = vec![]; + for e in self.top("Connections").into_iter().flat_map(|c| c.children.iter()).filter(|e| e.name == "C" || e.name == "Connect") { + let (Some(child), Some(parent)) = (key(e.props.get(1)), key(e.props.get(2))) else { continue }; + links.push((child, parent)); + b.children.entry(parent).or_default().push((child, e.str_at(3))); + } + + let models: Vec<(Key, &Record)> = + objects.children.iter().filter(|o| o.name == "Model").filter_map(|o| Some((key(o.props.first())?, o))).collect(); + let mut node_of: HashMap = HashMap::new(); + for (id, o) in &models { + if node_of.contains_key(id) { + continue; + } + b.node_room()?; + node_of.insert(*id, b.scene.nodes.len()); + b.scene.nodes.push(Node { + name: short_name(o.first_str().unwrap_or("")).to_string(), + transform: model_local(&Props::of(o)), + ..Node::default() + }); + } + + // Hierarchy: the first Model→Model link is the parent; anything else is a root. + let mut parent: HashMap = HashMap::new(); + for (c, p) in &links { + if let (Some(ci), Some(pi)) = (node_of.get(c), node_of.get(p)) + && ci != pi + { + parent.entry(*ci).or_insert(*pi); + } + } + for i in 0..b.scene.nodes.len() { + match parent.get(&i).and_then(|p| b.scene.nodes.get_mut(*p)) { + Some(p) => p.children.push(i), + None => b.scene.roots.push(i), + } + } + + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing + // the same geometry with the same materials share one mesh rather than each building a + // copy. + let mut mesh_of: HashMap<(Key, Vec), usize> = HashMap::new(); + for (id, o) in &models { + let Some(ni) = node_of.get(id).copied() else { continue }; + let Some((gid, geom)) = b.geometry_of(*id) else { continue }; + let mut mats: Vec = vec![]; + for (mid, _) in b.children_of(*id, "Material") { + mats.extend(b.material(mid)?); + } + let name = b.scene.nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(i) => *i, + None => { + let Some(mesh) = b.mesh(&name, geom, &mats)? else { continue }; + b.budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + if b.scene.meshes.len() >= limits.max_meshes { + return Err(too_large(KIND, format!("more than {} meshes", limits.max_meshes))); + } + b.scene.meshes.push(mesh); + mesh_of.insert((gid, mats), b.scene.meshes.len() - 1); + b.scene.meshes.len() - 1 + } + }; + match geometric(&Props::of(o)) { + Some(transform) => { + b.node_room()?; + let gi = b.scene.nodes.len(); + b.scene.nodes.push(Node { name: format!("{name} geometry"), transform, mesh: Some(mesh_index), ..Node::default() }); + if let Some(n) = b.scene.nodes.get_mut(ni) { + n.children.push(gi); + } + } + None => { + if let Some(n) = b.scene.nodes.get_mut(ni) { + n.mesh = Some(mesh_index); + } + } + } + } + let mut scene = b.scene; + count_warning(&mut scene.warnings, b.skipped, "polygons with fewer than three corners or a vertex that is not there were left out"); + Ok(scene) + } +} + +/// What connections name an object by: its id, or its `Class::Name`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Key<'a> { + Id(i64), + Name(&'a str), +} + +fn key(p: Option<&Prop>) -> Option> { + match p? { + Prop::Int(i) => Some(Key::Id(*i)), + Prop::Str(s) => Some(Key::Name(s)), + _ => None, + } +} + +fn short_name(full: &str) -> &str { + full.split_once("::").map_or(full, |(_, n)| n) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mapping { + PolygonVertex, + Vertex, + Polygon, + AllSame, +} + +/// How a geometry layer element (normals, UVs) is indexed. +struct Layer { + mapping: Mapping, + index: Option>, + /// Entries in the element's array. + len: usize, +} + +impl Layer { + /// The layer and its flat array of `width` numbers per entry. + fn of(geom: &Record, element: &str, data: &str, index: &[&str], width: usize) -> Option<(Layer, Vec)> { + let e = geom.child(element)?; + let mapping = match e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("ByPolygonVertex") { + "ByPolygonVertex" => Mapping::PolygonVertex, + "ByVertex" | "ByVertice" | "ByControlPoint" => Mapping::Vertex, + "ByPolygon" => Mapping::Polygon, + "AllSame" => Mapping::AllSame, + _ => return None, + }; + let reference = e.child("ReferenceInformationType").and_then(|m| m.str_at(0)).unwrap_or("Direct"); + let index = (reference != "Direct").then(|| index.iter().find_map(|n| e.child(n)).map(Record::ints)).flatten(); + let data = e.child(data)?.floats(); + Some((Layer { mapping, index, len: data.len() / width }, data)) + } + + /// The entry for corner `pv` of the geometry, at vertex `v` of polygon `poly`; [`NONE`] when + /// the file has none there. + fn at(layer: Option<&Layer>, pv: usize, v: usize, poly: usize) -> u32 { + let entry = |l: &Layer| { + let i = match l.mapping { + Mapping::PolygonVertex => pv, + Mapping::Vertex => v, + Mapping::Polygon => poly, + Mapping::AllSame => 0, + }; + let j = match &l.index { + Some(ix) => usize::try_from(*ix.get(i)?).ok()?, + None => i, + }; + u32::try_from(j).ok().filter(|_| j < l.len) + }; + layer.and_then(entry).unwrap_or(NONE) + } +} + +fn finite(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +/// Lambert/Phong surface properties onto metallic-roughness. +fn material(name: &str, record: &Record) -> Material { + let p = Props::of(record); + let phong = record.child("ShadingModel").and_then(|s| s.str_at(0)).is_some_and(|s| s.eq_ignore_ascii_case("phong")); + let factor = p.f("DiffuseFactor").unwrap_or(1.0); + let d = p.v3("DiffuseColor", p.v3("Diffuse", [0.8; 3])); + let alpha = match p.f("Opacity") { + Some(o) => o, + None => { + let tc = p.v3("TransparentColor", [0.0; 3]); + 1.0 - (tc[0] + tc[1] + tc[2]) / 3.0 * p.f("TransparencyFactor").unwrap_or(0.0) + } + } + .clamp(0.0, 1.0); + let lin = |c: f64| srgb_to_linear((c * factor).clamp(0.0, 1.0) as f32); + let e = p.v3("EmissiveColor", [0.0; 3]); + let ef = p.f("EmissiveFactor").unwrap_or(1.0); + let roughness = if phong { + let ns = p.f("ShininessExponent").or_else(|| p.f("Shininess")).unwrap_or(20.0).max(0.0); + (2.0 / (ns + 2.0)).sqrt() as f32 + } else { + 1.0 + }; + Material { + name: name.to_string(), + base_color: [lin(d[0]), lin(d[1]), lin(d[2]), alpha as f32], + metallic: 0.0, + roughness, + emissive: e.map(|c| srgb_to_linear((c * ef).clamp(0.0, 1.0) as f32)), + alpha_mode: if alpha < 0.999 { AlphaMode::Blend } else { AlphaMode::Opaque }, + ..Material::default() + } +} + +/// A scene under construction. +struct Build<'a, 'r> { + limits: &'a Limits, + budget: Budget, + resolve: Resolver<'r>, + by_key: HashMap, &'a Record>, + children: HashMap, Vec<(Key<'a>, Option<&'a str>)>>, + scene: Scene, + material_of: HashMap, usize>, + texture_of: HashMap, Option>, + /// Images by the source used (the embedded Video, else the file name the resolver took, else + /// the first name): Texture objects aliasing one image share it instead of each keeping a + /// copy. + image_of: HashMap<(Option>, String), usize>, + triangles: usize, + skipped: usize, +} + +impl<'a> Build<'a, '_> { + /// The objects of `kind` connected under `id`, with the property each connects to. + fn children_of(&self, id: Key<'a>, kind: &str) -> Vec<(Key<'a>, Option<&'a str>)> { + let all = self.children.get(&id).map(Vec::as_slice).unwrap_or_default(); + all.iter().filter(|l| self.by_key.get(&l.0).is_some_and(|o| o.name == kind)).copied().collect() + } + + fn node_room(&self) -> Result<()> { + if self.scene.nodes.len() >= self.limits.max_nodes { + return Err(too_large(KIND, format!("more than {} nodes", self.limits.max_nodes))); + } + Ok(()) + } + + /// The mesh geometry of Model `id`, and what it is shared by. + fn geometry_of(&self, id: Key<'a>) -> Option<(Key<'a>, &'a Record)> { + // 6.x: the Model holds its own mesh. + if let Some(model) = self.by_key.get(&id).copied().filter(|o| o.child("Vertices").is_some()) { + return Some((id, model)); + } + let (gid, _) = self.children_of(id, "Geometry").into_iter().next()?; + Some((gid, self.by_key.get(&gid).copied()?)) + } + + /// The image of Texture `tid`. + fn texture(&mut self, tid: Key<'a>) -> Result> { + if let Some(t) = self.texture_of.get(&tid) { + return Ok(*t); + } + let image = self.image(tid)?; + self.texture_of.insert(tid, image); + Ok(image) + } + + fn image(&mut self, tid: Key<'a>) -> Result> { + let Some(tex) = self.by_key.get(&tid).copied() else { return Ok(None) }; + let files: Vec = ["RelativeFilename", "FileName"].iter().filter_map(|k| Some(tex.child(k)?.str_at(0)?.replace('\\', "/"))).collect(); + for f in &files { + if !f.is_empty() && !self.scene.external.contains(f) { + self.scene.external.push(f.clone()); + } + } + let embedded = self.children_of(tid, "Video").into_iter().find_map(|(v, _)| { + let content = self.by_key.get(&v)?.child("Content")?; + let bytes = match content.props.first()? { + Prop::Bytes(b) if !b.is_empty() => Some(b.clone()), + Prop::Str(_) => { + let s: String = content.props.iter().filter_map(Prop::as_str).collect(); + base64_decode(&s).filter(|b| !b.is_empty()) + } + _ => None, + }?; + Some(((Some(v), String::new()), bytes)) + }); + let supplied = |resolve: &mut Resolver| { + files.iter().find_map(|f| { + let (name, bytes) = resolve(f).map(|b| (f.clone(), b)).or_else(|| { + let base = f.rsplit('/').next()?; + Some((base.to_string(), resolve(base)?)) + })?; + Some(((None, name), bytes)) + }) + }; + let (source, data) = match embedded.or_else(|| supplied(&mut self.resolve)) { + Some((source, bytes)) => (source, Some(bytes)), + None => match files.first() { + Some(f) => ((None, f.clone()), None), + None => return Ok(None), + }, + }; + if let Some(i) = self.image_of.get(&source) { + return Ok(Some(*i)); + } + self.budget.take(Some(data.as_ref().map_or(0, Vec::len)), "an image")?; + if self.scene.images.len() >= self.limits.max_materials { + return Err(too_large(KIND, format!("more than {} images", self.limits.max_materials))); + } + let uri = if source.0.is_some() { files.first().cloned() } else { Some(source.1.clone()) }; + self.scene.images.push(Image { name: short_name(tex.first_str().unwrap_or("")).to_string(), uri, mime_type: None, data }); + self.image_of.insert(source, self.scene.images.len() - 1); + Ok(Some(self.scene.images.len() - 1)) + } + + /// The scene's material for Material object `mid`, made on first use. + fn material(&mut self, mid: Key<'a>) -> Result> { + if let Some(m) = self.material_of.get(&mid) { + return Ok(Some(*m)); + } + let Some(record) = self.by_key.get(&mid).copied() else { return Ok(None) }; + let mut m = material(short_name(record.first_str().unwrap_or("")), record); + for (tid, prop) in self.children_of(mid, "Texture") { + let slot = match prop { + Some("DiffuseColor") => &mut m.base_color_texture, + Some("NormalMap") => &mut m.normal_texture, + Some("EmissiveColor") => &mut m.emissive_texture, + _ => continue, + }; + if slot.is_none() { + *slot = self.texture(tid)?.map(|image| TextureRef { image, ..TextureRef::default() }); + } + } + if m.emissive_texture.is_some() && m.emissive == [0.0; 3] { + m.emissive = [1.0; 3]; + } + if self.scene.materials.len() >= self.limits.max_materials { + return Err(too_large(KIND, format!("more than {} materials", self.limits.max_materials))); + } + self.scene.materials.push(m); + self.material_of.insert(mid, self.scene.materials.len() - 1); + Ok(Some(self.scene.materials.len() - 1)) + } + + /// A Mesh geometry; `materials[k]` is the scene's material for the geometry's slot `k`. + /// `None` when it has no polygon. + fn mesh(&mut self, name: &str, geom: &Record, materials: &[usize]) -> Result> { + let limits = self.limits; + let (Some(vertices), Some(pvi)) = (geom.child("Vertices"), geom.child("PolygonVertexIndex")) else { return Ok(None) }; + let pool = |what: &str, n: usize| { + if n > limits.max_vertices { Err(too_large(KIND, format!("more than {} {what}", limits.max_vertices))) } else { Ok(()) } + }; + let positions: Vec<[f64; 3]> = vertices.floats().as_chunks::<3>().0.iter().map(|c| c.map(finite)).collect(); + pool("vertices", positions.len())?; + let normals = Layer::of(geom, "LayerElementNormal", "Normals", &["NormalsIndex", "NormalIndex"], 3); + let uvs = Layer::of(geom, "LayerElementUV", "UV", &["UVIndex"], 2); + let slots = geom.child("LayerElementMaterial").and_then(|e| { + let mapping = e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("AllSame"); + Some((mapping == "ByPolygon", e.child("Materials")?.ints())) + }); + let (normal_layer, uv_layer) = (normals.as_ref().map(|l| &l.0), uvs.as_ref().map(|l| &l.0)); + + let mut parts: Vec = vec![]; + let mut part_of: HashMap, usize> = HashMap::new(); + let mut face: Vec = vec![]; + let (mut broken, mut poly) = (false, 0usize); + for (pv, raw) in pvi.ints().into_iter().enumerate() { + // The last corner of a polygon is stored as `-index - 1`. + let (v, last) = if raw < 0 { (!raw, true) } else { (raw, false) }; + match u32::try_from(v).ok().filter(|v| (*v as usize) < positions.len()) { + Some(p) => face.push(Corner { + position: p, + normal: Layer::at(normal_layer, pv, p as usize, poly), + uv: Layer::at(uv_layer, pv, p as usize, poly), + }), + None => broken = true, + } + if face.len() > limits.max_face_corners { + return Err(too_large(KIND, format!("a polygon of more than {} corners", limits.max_face_corners))); + } + if !last { + continue; + } + let (corners, p_index, was_broken) = (std::mem::take(&mut face), poly, std::mem::take(&mut broken)); + poly += 1; + if was_broken || corners.len() < 3 { + self.skipped += 1; + continue; + } + self.triangles = self.triangles.saturating_add(corners.len() - 2); + if self.triangles > limits.max_triangles { + return Err(too_large(KIND, format!("more than {} triangles", limits.max_triangles))); + } + let slot = slots.as_ref().and_then(|(by_poly, ids)| { + let k = if *by_poly { *ids.get(p_index)? } else { *ids.first()? }; + materials.get(usize::try_from(k).ok()?).copied() + }); + let slot = slot.or_else(|| materials.first().copied()); + let at = match part_of.get(&slot) { + Some(at) => *at, + None => { + if parts.len() >= limits.max_parts { + return Err(too_large(KIND, format!("more than {} material slots in a mesh", limits.max_parts))); + } + parts.push(Part { material: slot, ..Part::default() }); + part_of.insert(slot, parts.len() - 1); + parts.len() - 1 + } + }; + let Some(part) = parts.get_mut(at) else { continue }; + part.face_sizes.push(corners.len() as u32); + part.corners.extend(corners); + } + if parts.is_empty() { + return Ok(None); + } + for p in &mut parts { + if p.face_sizes.iter().all(|n| *n == 3) { + p.face_sizes = vec![]; + } + } + let normals: Vec<[f64; 3]> = normals.map(|(_, d)| d.as_chunks::<3>().0.iter().map(|c| c.map(finite)).collect()).unwrap_or_default(); + pool("normals", normals.len())?; + // FBX's v axis points up the image. + let uvs: Option> = + uvs.map(|(_, d)| d.as_chunks::<2>().0.iter().map(|c| [finite(c[0]) as f32, 1.0 - finite(c[1]) as f32]).collect()); + pool("UVs", uvs.as_ref().map_or(0, Vec::len))?; + Ok(Some(Mesh { name: name.to_string(), positions, normals, uv_sets: uvs.into_iter().collect(), parts, ..Mesh::default() })) + } +} diff --git a/crates/mesh/src/gltf.rs b/crates/mesh/src/gltf.rs index 330507191..aecc545ce 100644 --- a/crates/mesh/src/gltf.rs +++ b/crates/mesh/src/gltf.rs @@ -109,32 +109,7 @@ pub fn glb_container(json: &[u8], bin: &[u8]) -> Result> { Ok(out) } -/// Decode standard or URL-safe base64 (with or without padding; whitespace ignored). -pub fn base64_decode(s: &str) -> Option> { - let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3); - let mut acc = 0u32; - let mut bits = 0; - for c in s.bytes() { - let v = match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - b'=' => break, - b' ' | b'\n' | b'\r' | b'\t' => continue, - _ => return None, - }; - acc = (acc << 6) | u32::from(v); - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((acc >> bits) as u8); - acc &= (1 << bits) - 1; - } - } - Some(out) -} +pub use crate::base64_decode; /// Percent-decode a URI. A `%` that two hex digits do not follow stays as it is. pub fn uri_decode(s: &str) -> String { diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs index 737569660..a30e292ba 100644 --- a/crates/mesh/src/lib.rs +++ b/crates/mesh/src/lib.rs @@ -1,7 +1,7 @@ //! 3D mesh file formats behind one neutral model. //! //! Each format is a module behind a Cargo feature of the same name: [`stl`], [`obj`] (with MTL), -//! [`ply`] and [`gltf`] (glTF 2.0 and GLB). A reader turns bytes into a [`Scene`]; a writer turns +//! [`ply`], [`gltf`] (glTF 2.0 and GLB) and [`fbx`]. A reader turns bytes into a [`Scene`]; a writer turns //! a [`Scene`] into bytes. Mapping a [`Scene`] onto an application's own mesh, body or document //! types is the caller's business, as are computing missing normals, tangents and UVs, welding, //! unit scaling and turning a file's up axis. @@ -28,8 +28,10 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] // The helpers the formats share are not all used by every subset of them. -#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf")), allow(dead_code, unused_imports))] +#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf", feature = "fbx")), allow(dead_code, unused_imports))] +#[cfg(feature = "fbx")] +pub mod fbx; #[cfg(feature = "gltf")] pub mod gltf; #[cfg(feature = "obj")] @@ -691,6 +693,33 @@ pub fn linear_to_srgb(c: f32) -> f32 { if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } } +/// Decode standard or URL-safe base64 (with or without padding; whitespace ignored). +pub fn base64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3); + let mut acc = 0u32; + let mut bits = 0; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' => break, + b' ' | b'\n' | b'\r' | b'\t' => continue, + _ => return None, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + /// Warnings that would repeat are counted and reported once. pub(crate) fn count_warning(warnings: &mut Vec, count: usize, what: &str) { if count > 0 { diff --git a/crates/mesh/src/tests.rs b/crates/mesh/src/tests.rs index c2d5b9e02..24382e987 100644 --- a/crates/mesh/src/tests.rs +++ b/crates/mesh/src/tests.rs @@ -2,6 +2,8 @@ use super::*; +#[cfg(feature = "fbx")] +mod fbx; #[cfg(feature = "gltf")] mod gltf; #[cfg(feature = "obj")] diff --git a/crates/mesh/src/tests/fbx.rs b/crates/mesh/src/tests/fbx.rs new file mode 100644 index 000000000..a58ddeb19 --- /dev/null +++ b/crates/mesh/src/tests/fbx.rs @@ -0,0 +1,629 @@ +//! FBX: the same scene as ASCII and as every binary layout, textures, limits and hostile files. + +use super::hammer; +use crate::fbx::{BINARY_MAGIC, Document, Prop, Record, is_binary, mesh_bytes, read, read_document, value_bytes}; +use crate::{AlphaMode, Corner, Error, Limits, NONE, Scene, mat_mul, no_external, transform_point}; + +/// A Z-up scene: a Null parent with a mesh child (rotated, scaled, with a geometric offset) +/// holding a quad and a triangle with a material each. +const ASCII: &str = r#"; FBX 7.4.0 project file +; ---------------------------------------------------- +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 7400 + Creator: "craft-mesh test" +} +GlobalSettings: { + Version: 1000 + Properties70: { + P: "UpAxis", "int", "Integer", "",2 + P: "UpAxisSign", "int", "Integer", "",1 + P: "FrontAxis", "int", "Integer", "",1 + P: "FrontAxisSign", "int", "Integer", "",-1 + P: "CoordAxis", "int", "Integer", "",0 + P: "CoordAxisSign", "int", "Integer", "",1 + P: "UnitScaleFactor", "double", "Number", "",2.54 + } +} +Objects: { + Geometry: 100, "Geometry::Shape", "Mesh" { + Vertices: *15 { + a: 0,0,0,1,0,0,1,1,0,0,1,0,0.5,0.5,1 + } + PolygonVertexIndex: *7 { + a: 0,1,2,-4,0,1,-5 + } + LayerElementNormal: 0 { + MappingInformationType: "ByPolygon" + ReferenceInformationType: "Direct" + Normals: *6 { + a: 0,0,1,0,-1,0 + } + } + LayerElementUV: 0 { + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "IndexToDirect" + UV: *8 { + a: 0,0,1,0,1,1,0,1 + } + UVIndex: *7 { + a: 0,1,2,3,0,1,2 + } + } + LayerElementMaterial: 0 { + MappingInformationType: "ByPolygon" + ReferenceInformationType: "IndexToDirect" + Materials: *2 { + a: 0,1 + } + } + } + Model: 200, "Model::Parent", "Null" { + Properties70: { + P: "Lcl Translation", "Lcl Translation", "", "A",10,0,0 + } + } + Model: 300, "Model::Shape", "Mesh" { + Properties70: { + P: "Lcl Rotation", "Lcl Rotation", "", "A",0,0,90 + P: "Lcl Scaling", "Lcl Scaling", "", "A",2,2,2 + P: "GeometricTranslation", "Vector3D", "Vector", "",0,0,1 + } + Shading: T + } + Material: 400, "Material::Red", "" { + ShadingModel: "phong" + Properties70: { + P: "DiffuseColor", "Color", "", "A",1,0,0 + P: "ShininessExponent", "Number", "", "A",98 + } + } + Material: 500, "Material::Glass", "" { + ShadingModel: "lambert" + Properties70: { + P: "DiffuseColor", "Color", "", "A",0,0,1 + P: "Opacity", "double", "Number", "",0.25 + } + } +} +Connections: { + ;Model::Parent, Model::RootNode + C: "OO",200,0 + C: "OO",300,200 + C: "OO",100,300 + C: "OO",400,300 + C: "OO",500,300 +} +"#; + +fn parse(bytes: &[u8]) -> crate::Result { + read(bytes, &Limits::default(), &mut no_external) +} + +fn within(bytes: &[u8], max_data_bytes: usize) -> crate::Result { + read(bytes, &Limits { max_data_bytes, ..Limits::default() }, &mut no_external) +} + +fn document(bytes: &[u8]) -> Document { + read_document(bytes, &Limits::default()).unwrap() +} + +fn check_scene(doc: &Document, m: &Scene) { + m.validate().unwrap(); + assert_eq!(m.meshes.len(), 1); + let names: Vec<&str> = m.nodes.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["Parent", "Shape", "Shape geometry"]); + assert_eq!(m.roots, vec![0]); + assert_eq!(m.nodes[0].children, vec![1]); + assert_eq!(m.nodes[1].children, vec![2]); + assert_eq!(m.nodes[2].mesh, Some(0)); + + let mesh = &m.meshes[0]; + assert_eq!((mesh.name.as_str(), mesh.positions.len(), mesh.parts.len()), ("Shape", 5, 2)); + let (quad, tri) = (&mesh.parts[0], &mesh.parts[1]); + // The quad stays whole; a normal per polygon; UVs through the index. + assert_eq!(quad.face_sizes, [4]); + assert_eq!(quad.corners, [0, 1, 2, 3].map(|i| Corner { position: i, normal: 0, uv: i })); + assert!(tri.face_sizes.is_empty()); + assert_eq!(tri.corners, [(0, 0), (1, 1), (4, 2)].map(|(p, uv)| Corner { position: p, normal: 1, uv })); + assert_eq!(mesh.normals, [[0.0, 0.0, 1.0], [0.0, -1.0, 0.0]]); + // v flipped to image rows. + assert_eq!(mesh.uv_sets, [vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]]); + + assert_eq!(m.materials.len(), 2); + let (red, glass) = (&m.materials[quad.material.unwrap()], &m.materials[tri.material.unwrap()]); + assert_eq!(red.name, "Red"); + assert_eq!(red.base_color, [1.0, 0.0, 0.0, 1.0]); + assert!((red.roughness - 0.02f32.sqrt()).abs() < 1e-6 && red.metallic == 0.0); + assert_eq!(glass.name, "Glass"); + assert_eq!(glass.base_color, [0.0, 0.0, 1.0, 0.25]); + assert_eq!((glass.alpha_mode, glass.roughness), (AlphaMode::Blend, 1.0)); + + // Parent translation, Z rotation, scale and the geometric offset; then Z-up → Y-up, which + // the scene leaves to the caller. + let inst = m.instances(&Limits::default()).unwrap(); + assert_eq!(inst.len(), 1); + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + let apex = transform_point(&inst[0].1, [0.5, 0.5, 1.0]); + assert!(close(apex, [9.0, 1.0, 4.0]), "{apex:?}"); + let apex = transform_point(&mat_mul(&doc.axes_to_y_up(), &inst[0].1), [0.5, 0.5, 1.0]); + assert!(close(apex, [9.0, 4.0, -1.0]), "{apex:?}"); + assert_eq!(doc.unit_scale(), Some(2.54)); +} + +/// A binary FBX of the same record tree (`a:` children become typed arrays). +fn write_binary(records: &[Record], version: u32, compress: bool) -> Vec { + fn prop(out: &mut Vec, p: &Prop, compress: bool) { + let array = |out: &mut Vec, t: u8, len: usize, raw: Vec| { + out.push(t); + out.extend((len as u32).to_le_bytes()); + let data = if compress { miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6) } else { raw }; + out.extend((compress as u32).to_le_bytes()); + out.extend((data.len() as u32).to_le_bytes()); + out.extend(data); + }; + match p { + Prop::Int(i) => match i32::try_from(*i) { + Ok(v) => { + out.push(b'I'); + out.extend(v.to_le_bytes()); + } + Err(_) => { + out.push(b'L'); + out.extend(i.to_le_bytes()); + } + }, + Prop::Float(f) => { + out.push(b'D'); + out.extend(f.to_le_bytes()); + } + Prop::Str(s) => { + let s = match s.split_once("::") { + Some((class, name)) => format!("{name}\0\x01{class}"), + None => s.clone(), + }; + out.push(b'S'); + out.extend((s.len() as u32).to_le_bytes()); + out.extend(s.as_bytes()); + } + Prop::Bytes(b) => { + out.push(b'R'); + out.extend((b.len() as u32).to_le_bytes()); + out.extend(b); + } + Prop::Ints(v) => array(out, b'i', v.len(), v.iter().flat_map(|&i| (i as i32).to_le_bytes()).collect()), + Prop::Floats(v) => array(out, b'd', v.len(), v.iter().flat_map(|f| f.to_le_bytes()).collect()), + } + } + fn record(out: &mut Vec, n: &Record, wide: bool, compress: bool) { + let w = if wide { 8 } else { 4 }; + let start = out.len(); + out.extend(vec![0u8; 3 * w]); + out.push(n.name.len() as u8); + out.extend(n.name.as_bytes()); + let props = match n.child("a") { + Some(a) if a.props.iter().all(|p| matches!(p, Prop::Int(_))) => vec![Prop::Ints(a.ints())], + Some(a) => vec![Prop::Floats(a.floats())], + None => n.props.clone(), + }; + let pstart = out.len(); + for p in &props { + prop(out, p, compress); + } + let plen = out.len() - pstart; + let kids: Vec<&Record> = n.children.iter().filter(|c| c.name != "a").collect(); + if !kids.is_empty() { + for k in kids { + record(out, k, wide, compress); + } + out.extend(vec![0u8; 3 * w + 1]); + } + let end = out.len(); + for (k, v) in [end, props.len(), plen].into_iter().enumerate() { + let at = start + k * w; + if wide { + out[at..at + 8].copy_from_slice(&(v as u64).to_le_bytes()); + } else { + out[at..at + 4].copy_from_slice(&(v as u32).to_le_bytes()); + } + } + } + let wide = version >= 7500; + let mut out = BINARY_MAGIC.to_vec(); + out.extend(version.to_le_bytes()); + for n in records { + record(&mut out, n, wide, compress); + } + out.extend(vec![0u8; if wide { 25 } else { 13 }]); + // Footer bytes after the closing null record. + out.extend([0xfa; 16]); + out +} + +#[test] +fn ascii_scene() { + assert!(!is_binary(ASCII.as_bytes())); + check_scene(&document(ASCII.as_bytes()), &parse(ASCII.as_bytes()).unwrap()); +} + +#[test] +fn binary_matches_ascii() { + let doc = document(ASCII.as_bytes()); + let ascii = parse(ASCII.as_bytes()).unwrap(); + for (version, compress) in [(7400, false), (7400, true), (7500, true), (6100, false)] { + let bin = write_binary(&doc.records, version, compress); + assert!(is_binary(&bin)); + let d = document(&bin); + assert_eq!(d.version, version); + assert_eq!(d.records.iter().map(|n| n.name.as_str()).collect::>(), ["FBXHeaderExtension", "GlobalSettings", "Objects", "Connections"]); + let m = parse(&bin).unwrap(); + check_scene(&d, &m); + assert_eq!(m, ascii, "version {version}, compressed {compress}"); + } +} + +/// [`ASCII`] with more objects before the end of `Objects` and more connections. +fn with(objects: &str, connections: &str) -> String { + ASCII + .replace("}\nConnections: {", &format!("{objects}}}\nConnections: {{")) + .replace("C: \"OO\",500,300\n", &format!("C: \"OO\",500,300\n{connections}")) +} + +fn b64(b: &[u8]) -> String { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in b.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for k in 0..4 { + s.push(if k <= c.len() { A[(n >> (18 - 6 * k) & 63) as usize] as char } else { '=' }); + } + } + s +} + +#[test] +fn embedded_named_and_supplied_textures() { + let text = with( + &format!( + "\tTexture: 600, \"Texture::Base\", \"\" {{\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}}\n\ + \tTexture: 700, \"Texture::Glow\", \"\" {{\n\t\tRelativeFilename: \"missing.png\"\n\t}}\n\ + \tTexture: 900, \"Texture::Bumps\", \"\" {{\n\t\tFileName: \"C:\\art\\bumps.png\"\n\t}}\n\ + \tVideo: 800, \"Video::Glow\", \"Clip\" {{\n\t\tContent: \"{}\"\n\t}}\n", + b64(b"embedded image") + ), + "\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"EmissiveColor\"\n\tC: \"OO\",800,700\n\tC: \"OP\",900,400, \"NormalMap\"\n", + ); + let mut asked = vec![]; + let m = read(text.as_bytes(), &Limits::default(), &mut |uri: &str| { + asked.push(uri.to_string()); + (uri == "maps/base.png").then(|| b"file image".to_vec()) + }) + .unwrap(); + m.validate().unwrap(); + let image = |t: Option| &m.images[t.unwrap().image]; + let red = m.materials.iter().find(|x| x.name == "Red").unwrap(); + let base = image(red.base_color_texture); + assert_eq!((base.name.as_str(), base.uri.as_deref(), base.data.as_deref()), ("Base", Some("maps/base.png"), Some(&b"file image"[..]))); + // Declined (as written, then by its last component): named only. + let bumps = image(red.normal_texture); + assert_eq!((bumps.uri.as_deref(), &bumps.data), (Some("C:/art/bumps.png"), &None)); + // Embedded content wins over the file, and an emissive texture lights the material. + let glass = m.materials.iter().find(|x| x.name == "Glass").unwrap(); + let glow = image(glass.emissive_texture); + assert_eq!((glow.uri.as_deref(), glow.data.as_deref()), (Some("missing.png"), Some(&b"embedded image"[..]))); + assert_eq!(glass.emissive, [1.0; 3]); + assert_eq!(m.images.len(), 3); + assert_eq!(asked, ["maps/base.png", "C:/art/bumps.png", "bumps.png"]); + assert_eq!(m.external, ["maps/base.png", "C:/art/bumps.png", "missing.png"]); + + // The same embedded content in a binary file is raw bytes. + let mut doc = document(text.as_bytes()); + let objects = doc.records.iter_mut().find(|r| r.name == "Objects").unwrap(); + let video = objects.children.iter_mut().find(|r| r.name == "Video").unwrap(); + video.children[0].props = vec![Prop::Bytes(b"embedded image".to_vec())]; + let bin = parse(&write_binary(&doc.records, 7400, true)).unwrap(); + assert!(bin.images.iter().any(|i| i.data.as_deref() == Some(&b"embedded image"[..]))); +} + +#[test] +fn textures_that_end_at_one_image_share_it() { + // Two Texture objects naming one file; a third reaching it through its second name. + let text = with( + "\tTexture: 600, \"Texture::Base\", \"\" {\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}\n\ + \tTexture: 700, \"Texture::Base again\", \"\" {\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}\n\ + \tTexture: 710, \"Texture::Other spelling\", \"\" {\n\t\tRelativeFilename: \"missing\\base.png\"\n\t\tFileName: \"maps\\base.png\"\n\t}\n", + "\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"DiffuseColor\"\n\tC: \"OP\",710,500, \"EmissiveColor\"\n", + ); + let image = b"twelve bytes".to_vec(); + let supply = |limit: usize| { + let limits = Limits { max_data_bytes: limit, ..Limits::default() }; + read(text.as_bytes(), &limits, &mut |uri: &str| (uri == "maps/base.png").then(|| image.clone())) + }; + let m = supply(1 << 30).unwrap(); + assert_eq!(m.images.len(), 1); + assert!(m.materials.iter().all(|x| x.base_color_texture.map(|t| t.image) == Some(0))); + assert_eq!(m.materials[1].emissive_texture.map(|t| t.image), Some(0)); + // The image's bytes count against the memory limit, once. + let values = value_bytes(&document(text.as_bytes()).records).unwrap(); + let mesh = mesh_bytes(&m.meshes[0]).unwrap(); + assert_eq!(supply(values + mesh + image.len()).unwrap().images.len(), 1); + assert!(matches!(supply(values + mesh + image.len() - 1), Err(Error::TooLarge(e)) if e.contains("memory limit"))); +} + +#[test] +fn hostile_files_fail_cleanly() { + let doc = document(ASCII.as_bytes()); + // Every truncation and two corruptions of every byte return (no panic, no runaway), and + // what is accepted is a valid scene. + hammer(&write_binary(&doc.records, 7500, true), &parse); + hammer(&write_binary(&doc.records, 7400, false), &parse); + hammer(ASCII.as_bytes(), &parse); + // An array claiming 4 G elements. + let mut huge = BINARY_MAGIC.to_vec(); + huge.extend(7400u32.to_le_bytes()); + let body: Vec = + b"d".iter().copied().chain(u32::MAX.to_le_bytes()).chain(0u32.to_le_bytes()).chain(8u32.to_le_bytes()).chain([0u8; 8]).collect(); + let end = huge.len() + 13 + 1 + body.len(); + huge.extend((end as u32).to_le_bytes()); + huge.extend(1u32.to_le_bytes()); + huge.extend((body.len() as u32).to_le_bytes()); + huge.push(1); + huge.push(b'V'); + huge.extend(body); + assert!(matches!(read_document(&huge, &Limits::default()), Err(Error::TooLarge(_)))); + // Deep nesting, both encodings. + assert!(matches!(read_document("A: {".repeat(100_000).as_bytes(), &Limits::default()), Err(Error::TooLarge(_)))); + let mut deep = Record { name: "Leaf".into(), ..Record::default() }; + for _ in 0..200 { + deep = Record { name: "N".into(), props: vec![], children: vec![deep] }; + } + assert!(matches!(read_document(&write_binary(&[deep], 7400, false), &Limits::default()), Err(Error::TooLarge(_)))); + // Not an FBX scene at all, and an FBX version that is not read. + assert!(matches!(parse(b"\x00\x01garbage"), Err(Error::Malformed(_)))); + assert!(parse(b"Kaydara FBX Binary \0\x1a\0").is_err()); + let v5 = "FBXHeaderExtension: {\n FBXVersion: 5800\n}\nObjects: {\n}\n"; + hammer(FBX6.as_bytes(), &parse); + hammer(&write_binary(&document(FBX6.as_bytes()).records, 6100, false), &parse); + assert!(matches!(parse(v5.as_bytes()), Err(Error::Unsupported(_)))); + // A scene without meshes is a scene. + let empty = parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n").unwrap(); + assert_eq!((empty.nodes.len(), empty.roots.as_slice(), empty.meshes.len()), (1, &[0][..], 0)); + // Polygons that name a vertex that is not there, or have two corners, are left out; layer + // indices past their arrays give corners without a normal. + let text = "Objects: {\n Geometry: 1, \"Geometry::G\", \"Mesh\" {\n Vertices: 0,0,0,1,0,0,0,1,0\n PolygonVertexIndex: 0,1,99,-3,0,-2,0,1,-3\n \ + LayerElementNormal: 0 {\n MappingInformationType: \"ByVertice\"\n ReferenceInformationType: \"IndexToDirect\"\n Normals: 0,0,1\n NormalsIndex: 0,7,-1\n }\n }\n \ + Model: 2, \"Model::M\", \"Mesh\" {\n }\n}\nConnections: {\n C: \"OO\",1,2\n C: \"OO\",2,2\n}\n"; + let m = parse(text.as_bytes()).unwrap(); + m.validate().unwrap(); + assert_eq!(m.meshes[0].parts[0].corners, [Corner { position: 0, normal: 0, uv: NONE }, Corner::at(1), Corner::at(2)]); + assert_eq!(m.warnings.len(), 1); + assert!(m.warnings[0].starts_with("2 polygons"), "{:?}", m.warnings); +} + +/// A version-7400 binary FBX holding one top-level record `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn arrays_are_charged_for_the_memory_they_keep() { + let doc = |bin: &[u8], max_data_bytes: usize| read_document(bin, &Limits { max_data_bytes, ..Limits::default() }); + // A boolean array is one byte per element in the file but eight once kept as `Prop::Ints`: + // the limit counts the eight. + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(doc(&bin, n * 8).is_ok()); + assert!(matches!(doc(&bin, n * 8 - 1), Err(Error::TooLarge(e)) if e.contains("memory limit"))); + // 32-bit floats widen to f64 the same way. + assert!(doc(&binary_arrays(&[(b'f', n)]), n * 4).is_err()); + // Several arrays each within the limit but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(doc(&bin, n * 24).is_ok()); + assert!(doc(&bin, n * 20).is_err()); + // ASCII values count too. + let values = value_bytes(&document(ASCII.as_bytes()).records).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(matches!(within(ASCII.as_bytes(), values - 1), Err(Error::TooLarge(e)) if e.contains("memory limit"))); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + with( + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n", + "\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn models_instancing_one_geometry_share_its_mesh() { + let text = instanced_scene(); + let m = parse(text.as_bytes()).unwrap(); + m.validate().unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + assert_eq!(m.roots.len(), 3); + // A limit with room for the two meshes, but not a third, still reads. + let meshes: usize = m.meshes.iter().map(|m| mesh_bytes(m).unwrap()).sum(); + let values = value_bytes(&document(text.as_bytes()).records).unwrap(); + assert!(within(text.as_bytes(), values + meshes).is_ok()); + assert!(within(text.as_bytes(), values + meshes - 1).is_err()); +} + +#[test] +fn binary_child_record_past_its_parent_is_an_error() { + let parent = Record { name: "P".into(), props: vec![], children: vec![Record { name: "C".into(), ..Record::default() }] }; + let after = Record { name: "Q".into(), props: vec![Prop::Int(1)], children: vec![] }; + let mut bin = write_binary(&[parent, after], 7400, false); + assert_eq!(document(&bin).records.len(), 2); + // `P` starts after the magic and version; `C` after P's 12-byte header, name length and name. + let p = BINARY_MAGIC.len() + 4; + let p_end = u32::from_le_bytes(bin[p..p + 4].try_into().unwrap()); + let c = p + 14; + bin[c..c + 4].copy_from_slice(&(p_end + 1).to_le_bytes()); + assert!(matches!(read_document(&bin, &Limits::default()), Err(Error::Malformed(e)) if e.contains("bad record end"))); +} + +#[test] +fn a_failed_array_inflate_reports_only_its_status() { + let doc = document(ASCII.as_bytes()); + let mut bin = write_binary(&doc.records, 7400, true); + // Break the zlib header of the first compressed array (`Vertices`: type, count, encoding 1, + // length, then the stream). + let at = bin.windows(8).position(|w| w == b"Vertices").unwrap() + 8 + 13; + bin[at] = 0xff; + let Err(Error::Malformed(e)) = read_document(&bin, &Limits::default()) else { panic!("read a broken array") }; + assert!(e.contains("array zlib") && e.len() < 80, "{e}"); +} + +#[test] +fn every_limit_is_kept() { + let text = instanced_scene(); + let refused = |limits: Limits| matches!(read(text.as_bytes(), &limits, &mut no_external), Err(Error::TooLarge(_))); + let d = Limits::default; + // 5 vertices; 3 triangles in each of 2 meshes; a quad; 2 material slots; 2 materials; + // 4 Models and 3 geometry nodes; 2 meshes. + for (at, under) in [ + (Limits { max_vertices: 5, ..d() }, Limits { max_vertices: 4, ..d() }), + (Limits { max_triangles: 6, ..d() }, Limits { max_triangles: 5, ..d() }), + (Limits { max_face_corners: 4, ..d() }, Limits { max_face_corners: 3, ..d() }), + (Limits { max_parts: 2, ..d() }, Limits { max_parts: 1, ..d() }), + (Limits { max_materials: 2, ..d() }, Limits { max_materials: 1, ..d() }), + (Limits { max_nodes: 5, ..d() }, Limits { max_nodes: 4, ..d() }), + (Limits { max_meshes: 2, ..d() }, Limits { max_meshes: 1, ..d() }), + ] { + assert!(!refused(at.clone()), "{at:?}"); + assert!(refused(under.clone()), "{under:?}"); + } + // Images share the materials' limit. + let textured = with( + "\tTexture: 600, \"Texture::A\", \"\" {\n\t\tFileName: \"a.png\"\n\t}\n\tTexture: 700, \"Texture::B\", \"\" {\n\t\tFileName: \"b.png\"\n\t}\n\tMaterial: 510, \"Material::Third\", \"\" {\n\t}\n", + "\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,400, \"NormalMap\"\n\tC: \"OO\",510,300\n", + ); + let images = |max_materials: usize| read(textured.as_bytes(), &Limits { max_materials, ..d() }, &mut no_external); + assert_eq!(images(3).unwrap().images.len(), 2); + assert!(matches!(images(1), Err(Error::TooLarge(_)))); +} + +#[test] +fn axes_that_are_not_a_turn_are_left_alone() { + let both_on_z = ASCII.replace("\"FrontAxis\", \"int\", \"Integer\", \"\",1", "\"FrontAxis\", \"int\", \"Integer\", \"\",2"); + assert_eq!(document(both_on_z.as_bytes()).axes_to_y_up(), crate::IDENTITY); + let no_settings = document(b"Objects: {\n}\n"); + assert_eq!((no_settings.axes_to_y_up(), no_settings.unit_scale()), (crate::IDENTITY, None)); +} + +/// FBX 6.1 as Blender's exporter wrote it: the Model holds the mesh, `Properties60`, objects +/// named in `Connect` lines, `GlobalSettings` among the objects. +const FBX6: &str = r#"; FBX 6.1.0 project file +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 6100 +} +Objects: { + Model: "Model::Root", "Null" { + Properties60: { + Property: "Lcl Scaling", "Lcl Scaling", "A+",2,2,2 + } + } + Model: "Model::Tri", "Mesh" { + Properties60: { + Property: "Lcl Translation", "Lcl Translation", "A+",0,5,0 + } + Vertices: 0,0,0,1,0,0,0,1,0,1,1,0 + PolygonVertexIndex: 0,1,-3,1,3,-3 + LayerElementNormal: 0 { + MappingInformationType: "ByVertice" + ReferenceInformationType: "IndexToDirect" + Normals: 0,0,1 + NormalIndex: 0,0,0,0 + } + LayerElementMaterial: 0 { + MappingInformationType: "ByPolygon" + ReferenceInformationType: "IndexToDirect" + Materials: 0,1 + } + } + Material: "Material::Blue", "" { + ShadingModel: "lambert" + Properties60: { + Property: "Diffuse", "ColorRGB", "",0,0,1 + } + } + Material: "Material::Green", "" { + Properties60: { + Property: "DiffuseColor", "ColorRGB", "",0,1,0 + } + } + GlobalSettings: { + Version: 1000 + Properties60: { + Property: "UpAxis", "int", "",2 + Property: "FrontAxis", "int", "",1 + Property: "FrontAxisSign", "int", "",-1 + Property: "UnitScaleFactor", "double", "",100 + } + } +} +Connections: { + Connect: "OO", "Model::Root", "Model::Scene" + Connect: "OO", "Model::Tri", "Model::Root" + Connect: "OO", "Material::Blue", "Model::Tri" + Connect: "OO", "Material::Green", "Model::Tri" +} +"#; + +#[test] +fn fbx6_model_holds_its_mesh() { + let doc = document(FBX6.as_bytes()); + let text = parse(FBX6.as_bytes()).unwrap(); + let binary = write_binary(&doc.records, 6100, false); + assert_eq!(document(&binary).version, 6100); + assert_eq!(parse(&binary).unwrap(), text); + for (m, d) in [(&text, &doc), (&parse(&binary).unwrap(), &document(&binary))] { + m.validate().unwrap(); + assert_eq!(m.nodes.iter().map(|n| n.name.as_str()).collect::>(), ["Root", "Tri"]); + assert_eq!((m.roots.as_slice(), m.nodes[0].children.as_slice(), m.nodes[1].mesh), (&[0][..], &[1][..], Some(0))); + let mesh = &m.meshes[0]; + assert_eq!((mesh.positions.len(), mesh.normals.as_slice()), (4, &[[0.0, 0.0, 1.0]][..])); + // A part per material slot, in the order the materials connect to the Model. + let colors: Vec<[f32; 4]> = mesh.parts.iter().map(|p| m.materials[p.material.unwrap()].base_color).collect(); + assert_eq!(colors, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]]); + assert_eq!(mesh.parts[1].corners, [1, 3, 2].map(|i| Corner { position: i, normal: 0, uv: NONE })); + // Translated by 5 inside a parent scaled by 2. + let world = m.instances(&Limits::default()).unwrap()[0].1; + assert_eq!(transform_point(&world, [1.0, 1.0, 0.0]), [2.0, 12.0, 0.0]); + // Z-up, from the settings among the objects. + assert_eq!(transform_point(&d.axes_to_y_up(), [1.0, 2.0, 3.0]), [1.0, 3.0, -2.0]); + assert_eq!(d.unit_scale(), Some(100.0)); + } +}