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)); + } +}