diff --git a/Cargo.lock b/Cargo.lock index ba84c75f..be72feda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1446,6 +1446,7 @@ version = "0.8.0" dependencies = [ "effectcraft-geom", "image", + "miniz_oxide 0.8.9", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b6101c5a..033d5740 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[ "Opus", "glTF/GLB", "OBJ", + "FBX", "Lottie JSON", ]; diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 0fd840ff..0a5403a7 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -1,4 +1,4 @@ -//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the +//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/FBX, Layer ▸ New ▸ 3D primitives), the //! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and //! shapes, and Layer ▸ Material commands. @@ -376,7 +376,7 @@ pub fn specs() -> Vec { "3D Model Layer", [], None, - "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", + "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj or .fbx file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", has_comp, new_model ), diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 3247a588..e9e98443 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,7 @@ const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif" const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; -const MODEL: &[&str] = &["gltf", "glb", "obj"]; +const MODEL: &[&str] = &["gltf", "glb", "obj", "fbx"]; /// Kind of file from its extension. pub fn kind_of(path: &str) -> Option<&'static str> { diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c1624..e85900c0 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -58,7 +58,7 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"]; /// File extensions recognised as 3D models (Advanced 3D model layers). -pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"]; +pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "fbx"]; /// Whether `path` has an extension we can import. pub fn is_importable(path: &std::path::Path) -> bool { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fd..938fa493 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { probe_bytes(&path.to_string_lossy(), bytes.into()) } -/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its +/// Probe a 3D model (glTF 2.0 / OBJ / FBX): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -31,6 +31,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option "OBJ", + Some("fbx") => "FBX", Some("glb") => "GLB", _ => "glTF", }; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d..3dcf41aa 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -454,6 +454,16 @@ fn png_with_alpha() { assert_eq!((th.width, th.height), (64, 32)); } +/// File ▸ Import of an FBX scene: importable by extension and probed as a 3D model item. +#[test] +fn fbx_imports_as_a_model() { + assert!(effectcraft_media::is_importable(Path::new("scene.fbx"))); + let fbx = "FBXHeaderExtension: {\n FBXVersion: 7400\n}\nObjects: {\n Geometry: 1, \"Geometry::Tri\", \"Mesh\" {\n Vertices: *9 {\n a: 0,0,0,1,0,0,0,1,0\n }\n PolygonVertexIndex: *3 {\n a: 0,1,-3\n }\n }\n Model: 2, \"Model::Tri\", \"Mesh\" {\n }\n}\nConnections: {\n C: \"OO\",2,0\n C: \"OO\",1,2\n}\n"; + let f = effectcraft_media::probe_bytes("scene.fbx", Arc::from(fbx.as_bytes())).expect("probe"); + assert_eq!(f.kind, FootageKind::Model); + assert_eq!(f.codec, "FBX (1 meshes, 1 triangles)"); +} + #[test] fn wav_audio() { let Some(p) = tone_wav() else { return }; diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 20a1fde0..965a7d08 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-model" -description = "3D models: glTF 2.0 (.gltf/.glb) and Wavefront OBJ/MTL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" +description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL and FBX 7.x (binary and ASCII) import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" version.workspace = true edition.workspace = true license.workspace = true @@ -15,3 +15,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } image = { workspace = true } +miniz_oxide = "0.8" diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs new file mode 100644 index 00000000..e2b9e1bd --- /dev/null +++ b/crates/model/src/fbx.rs @@ -0,0 +1,875 @@ +//! FBX 7.x scenes, binary and ASCII. +//! +//! Written from public descriptions of the format (the binary node-record layout, the ASCII +//! syntax, and the scene objects of the Autodesk FBX SDK documentation): no importer source was +//! read. +//! +//! - **Binary**: the `Kaydara FBX Binary` header and version, node records with 32-bit (before +//! 7.5) or 64-bit offsets, every property type (`Y C I F D L S R` and the `f d l i b` arrays, +//! zlib-compressed or not). Object names `Name\0\x01Class` become `Class::Name` as in ASCII. +//! - **ASCII**: `Key: value, value { children }` with `;` comments and `*N { a: … }` arrays. +//! - **Scene**: `Geometry` meshes (`Vertices`, `PolygonVertexIndex` polygons fan-triangulated, +//! the first `LayerElementNormal`, `LayerElementUV` and `LayerElementMaterial` with every +//! mapping and reference mode), `Model` nodes with the SDK transform (translation, rotation +//! offset and pivot, pre/post rotation, Euler rotation in its order, scaling offset and pivot, +//! scaling) and geometric transforms, `Material` (Lambert/Phong mapped onto +//! metallic-roughness), `Texture` and `Video` (embedded or relative image files), joined by +//! `Connections`. `GlobalSettings` axes turn the scene to +Y up. +//! +//! Not imported: FBX 6.x and older, animation stacks, skin deformers, blend shapes, cameras and +//! lights, NURBS and patches. Units stay the file's own (like OBJ). + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::obj::srgb_to_linear; +use crate::{AlphaMode, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, TexRef, Texture, perr, vec3}; + +/// 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 node nesting accepted (real files nest well under 10 levels). +const MAX_DEPTH: usize = 64; +/// Largest decoded array accepted (bytes). +const MAX_ARRAY_BYTES: usize = 1 << 30; + +/// One property value of an FBX node. +#[derive(Clone, Debug, PartialEq)] +pub enum Prop { + Int(i64), + Float(f64), + Str(String), + Bytes(Vec), + Ints(Vec), + Floats(Vec), +} + +impl Prop { + fn as_f64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i as f64), + Prop::Float(f) => Some(*f), + _ => None, + } + } + fn as_i64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i), + Prop::Float(f) if f.is_finite() => Some(*f as i64), + _ => None, + } + } + fn as_str(&self) -> Option<&str> { + match self { + Prop::Str(s) => Some(s), + _ => None, + } + } +} + +/// A node of the FBX document tree (the same for binary and ASCII files). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FbxNode { + pub name: String, + pub props: Vec, + pub children: Vec, +} + +impl FbxNode { + pub fn child(&self, name: &str) -> Option<&FbxNode> { + 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 prop(&self, i: usize) -> Option<&Prop> { + self.props.get(i) + } + fn str_at(&self, i: usize) -> Option<&str> { + self.prop(i).and_then(Prop::as_str) + } + /// The node'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 node's array as integers (see [`FbxNode::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 + } +} + +/// Read an FBX file (binary or ASCII) into its top-level nodes and the file version +/// (e.g. 7400). +pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } +} + +// --------------------------------------------------------------------------------------------- +// Binary + +struct Bin<'a> { + d: &'a [u8], + /// 64-bit record offsets (version 7500 and later). + wide: bool, +} + +impl Bin<'_> { + fn take(&self, pos: &mut usize, n: usize) -> Result<&[u8]> { + let end = pos.checked_add(n).ok_or_else(|| perr("FBX: offset overflow"))?; + let s = self.d.get(*pos..end).ok_or_else(|| perr(format!("FBX: truncated at byte {pos}")))?; + *pos = end; + Ok(s) + } + fn arr(&self, pos: &mut usize) -> Result<[u8; N]> { + let s = self.take(pos, N)?; + let mut a = [0u8; N]; + a.copy_from_slice(s); + 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(self.u32(pos)? as u64) } + } + fn to_usize(v: u64) -> Result { + usize::try_from(v).map_err(|_| perr("FBX: 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(perr("FBX: nodes nested too deeply")); + } + let mut out = vec![]; + while *pos < end { + match self.record(pos, depth)? { + Some(n) => out.push(n), + None => break, + } + } + Ok(out) + } + + /// One node record; `None` for the null record that closes a list. + fn record(&self, pos: &mut 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 = self.arr::<1>(pos)?[0] as usize; + if end == 0 && nprops == 0 && plen == 0 && nlen == 0 { + return Ok(None); + } + if end <= start || end > self.d.len() { + return Err(perr(format!("FBX: 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(|| perr(format!("FBX: `{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(perr(format!("FBX: `{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(FbxNode { name, props, children })) + } + + fn prop(&self, pos: &mut usize, pend: usize) -> Result { + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let t = sub.arr::<1>(pos)?[0]; + Ok(match t { + b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), + b'C' => Prop::Int((sub.arr::<1>(pos)?[0] != 0) as i64), + b'I' => Prop::Int(i32::from_le_bytes(sub.arr(pos)?) as i64), + b'L' => Prop::Int(i64::from_le_bytes(sub.arr(pos)?)), + b'F' => Prop::Float(f32::from_le_bytes(sub.arr(pos)?) as f64), + 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(|| perr("FBX: array too large"))?; + let data = sub.take(pos, clen)?; + let raw = match enc { + 0 => data.to_vec(), + 1 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, want).map_err(|e| perr(format!("FBX: array zlib: {e:?}")))?, + e => return Err(perr(format!("FBX: unknown array encoding {e}"))), + }; + let raw = raw.get(..want).ok_or_else(|| perr("FBX: array shorter than its length"))?; + match t { + b'f' => Prop::Floats(raw.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c) as f64).collect()), + b'd' => Prop::Floats(raw.as_chunks::<8>().0.iter().map(|c| f64::from_le_bytes(*c)).collect()), + b'l' => Prop::Ints(raw.as_chunks::<8>().0.iter().map(|c| i64::from_le_bytes(*c)).collect()), + b'i' => Prop::Ints(raw.as_chunks::<4>().0.iter().map(|c| i32::from_le_bytes(*c) as i64).collect()), + _ => Prop::Ints(raw.iter().map(|&b| (b != 0) as i64).collect()), + } + } + t => return Err(perr(format!("FBX: 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]) -> Result<(u32, Vec)> { + let mut pos = BINARY_MAGIC.len(); + let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500 }; + let mut nodes = vec![]; + while pos < bytes.len() { + match bin.record(&mut pos, 0) { + Ok(Some(n)) => nodes.push(n), + Ok(None) => break, + // A damaged tail (footer, padding) after complete sections is ignored. + Err(_) if nodes.iter().any(|n| n.name == "Objects") => break, + Err(e) => return Err(e), + } + } + Ok((version, nodes)) +} + +// --------------------------------------------------------------------------------------------- +// 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(perr("FBX: nodes 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 node = FbxNode { name: name.clone(), ..FbxNode::default() }; + loop { + match toks.get(*i) { + Some(Tok::Val(v)) => node.props.push(v.clone()), + Some(Tok::Count | Tok::Comma) => {} + _ => break, + } + *i += 1; + } + if toks.get(*i) == Some(&Tok::Open) { + *i += 1; + node.children = ascii_list(toks, i, depth + 1)?; + } + out.push(node); + } + _ => {} + } + } + Ok(out) +} + +fn read_ascii(text: &str) -> Result<(u32, Vec)> { + let toks = tokenize(text); + let mut i = 0; + let nodes = ascii_list(&toks, &mut i, 0)?; + let version = nodes + .iter() + .find(|n| n.name == "FBXHeaderExtension") + .and_then(|h| h.child("FBXVersion")) + .and_then(|v| v.prop(0)) + .and_then(Prop::as_i64) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(0); + Ok((version, nodes)) +} + +// --------------------------------------------------------------------------------------------- +// Scene + +/// `Properties70` entries by name (`P: name, type, label, flags, values…`). +struct Props70<'a>(HashMap<&'a str, &'a FbxNode>); + +impl<'a> Props70<'a> { + fn of(node: &'a FbxNode) -> Props70<'a> { + let mut m = HashMap::new(); + if let Some(p) = node.child("Properties70") { + for e in p.children_named("P") { + if let Some(name) = e.str_at(0) { + m.entry(name).or_insert(e); + } + } + } + Props70(m) + } + fn values(&self, name: &str) -> Vec { + self.0.get(name).map(|e| e.props.iter().skip(4).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, + } + } +} + +/// 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) = (Mat4::rotate_x(d[0]), Mat4::rotate_y(d[1]), Mat4::rotate_z(d[2])); + match order { + 1 => y * z * x, + 2 => x * z * y, + 3 => z * x * y, + 4 => y * x * z, + 5 => x * y * z, + _ => z * y * x, + } +} + +fn translate(t: [f64; 3]) -> Mat4 { + Mat4::translate(vec3(t[0], t[1], t[2])) +} + +/// A Model's local transform: `T · Roff · Rp · Rpre · R · Rpost⁻¹ · Rp⁻¹ · Soff · Sp · S · Sp⁻¹`. +fn model_local(p: &Props70) -> 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), euler(p.v3("PostRotation", [0.0; 3]), 0).transpose()) } else { (Mat4::IDENTITY, Mat4::IDENTITY) }; + let rp = p.v3("RotationPivot", [0.0; 3]); + let sp = p.v3("ScalingPivot", [0.0; 3]); + let s = p.v3("Lcl Scaling", [1.0; 3]); + 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[0], -rp[1], -rp[2]]) + * translate(p.v3("ScalingOffset", [0.0; 3])) + * translate(sp) + * Mat4::scale(vec3(s[0], s[1], s[2])) + * translate([-sp[0], -sp[1], -sp[2]]) +} + +/// The geometric transform of a Model (applies to its geometry, not to its children). +fn geometric(p: &Props70) -> 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(translate(t) * euler(r, 0) * Mat4::scale(vec3(s[0], s[1], s[2]))) +} + +/// The file's axis system → +Y up, +Z front, +X right. +fn axis_matrix(settings: Option<&FbxNode>) -> Mat4 { + let p = settings.map(Props70::of); + 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; 4]> { + let a = a as i64; + let sign = if s < 0.0 { -1.0 } else { 1.0 }; + let mut v = [0.0; 4]; + *v.get_mut(usize::try_from(a).ok().filter(|&a| a < 3)?)? = sign; + 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 Mat4::IDENTITY; + }; + let m = Mat4([right, up, front, [0.0, 0.0, 0.0, 1.0]]); + // Two axes on the same file axis: not a rotation. + let used = |k: usize| right[k] != 0.0 || up[k] != 0.0 || front[k] != 0.0; + if (0..3).all(used) { m } else { Mat4::IDENTITY } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mapping { + PolygonVertex, + Vertex, + Polygon, + AllSame, +} + +/// A geometry layer element (normals, UVs, material indices). +struct LayerData { + mapping: Mapping, + data: Vec, + index: Option>, + width: usize, +} + +impl LayerData { + fn of(geom: &FbxNode, element: &str, data: &str, index: &str, width: usize) -> Option { + 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(|| e.child(index).map(FbxNode::ints)).flatten(); + Some(LayerData { mapping, data: e.child(data)?.floats(), index, width }) + } + fn get(&self, pv: usize, v: usize, poly: usize) -> Option<&[f64]> { + let i = match self.mapping { + Mapping::PolygonVertex => pv, + Mapping::Vertex => v, + Mapping::Polygon => poly, + Mapping::AllSame => 0, + }; + let j = match &self.index { + Some(ix) => usize::try_from(*ix.get(i)?).ok()?, + None => i, + }; + let a = j.checked_mul(self.width)?; + self.data.get(a..a.checked_add(self.width)?) + } +} + +fn f32s(s: &[f64]) -> [f32; N] { + let mut a = [0.0f32; N]; + for (o, v) in a.iter_mut().zip(s) { + *o = if v.is_finite() { *v as f32 } else { 0.0 }; + } + a +} + +/// A Mesh geometry with its polygons triangulated; `materials[k]` is the model-level material +/// index of the geometry's material slot `k`. +fn build_mesh(name: &str, geom: &FbxNode, materials: &[usize], with_normal_maps: &dyn Fn(usize) -> bool) -> Option { + let positions: Vec<[f32; 3]> = geom.child("Vertices")?.floats().as_chunks::<3>().0.iter().map(|c| f32s::<3>(c)).collect(); + let pvi = geom.child("PolygonVertexIndex")?.ints(); + let normals = LayerData::of(geom, "LayerElementNormal", "Normals", "NormalsIndex", 3); + let uvs = LayerData::of(geom, "LayerElementUV", "UV", "UVIndex", 2); + let mats = geom.child("LayerElementMaterial").and_then(|e| { + let mapping = e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("AllSame"); + let ids = e.child("Materials")?.ints(); + Some((mapping == "ByPolygon", ids)) + }); + + type Key = (u32, [u32; 3], [u32; 2]); + let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut corners: Vec<(usize, usize)> = vec![]; + let mut poly = 0usize; + for (pv, &raw) in pvi.iter().enumerate() { + // The last corner of a polygon is stored as `-index - 1`. + let (v, last) = if raw < 0 { (!raw, true) } else { (raw, false) }; + corners.push((pv, usize::try_from(v).unwrap_or(usize::MAX))); + if !last { + continue; + } + let poly_corners = std::mem::take(&mut corners); + let p_index = poly; + poly += 1; + if poly_corners.len() < 3 || poly_corners.iter().any(|&(_, v)| v >= positions.len()) { + continue; + } + let slot = mats.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 prims.iter().position(|p| p.0 == slot) { + Some(i) => i, + None => { + prims.push((slot, Primitive { material: slot, ..Default::default() }, HashMap::new(), vec![])); + prims.len() - 1 + } + }; + let Some((_, prim, map, need)) = prims.get_mut(at) else { continue }; + let mut ids = Vec::with_capacity(poly_corners.len()); + for &(pv, v) in &poly_corners { + let n = normals.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<3>); + let t = uvs.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<2>); + let key: Key = (v as u32, n.unwrap_or([f32::NAN; 3]).map(f32::to_bits), t.unwrap_or([f32::NAN; 2]).map(f32::to_bits)); + let id = *map.entry(key).or_insert_with(|| { + prim.positions.push(positions.get(v).copied().unwrap_or_default()); + prim.normals.push(n.unwrap_or_default()); + prim.uvs.push(t.unwrap_or_default()); + need.push(n.is_none()); + (prim.positions.len() - 1) as u32 + }); + ids.push(id); + } + for i in 2..ids.len() { + prim.indices.extend([ids[0], ids[i - 1], ids[i]]); + } + } + let mut mesh = Mesh { name: name.to_string(), primitives: vec![] }; + for (_, mut p, _, need) in prims { + if p.indices.is_empty() { + continue; + } + if need.iter().any(|&b| b) { + let given = p.normals.clone(); + p.compute_normals(); + for ((n, g), need) in p.normals.iter_mut().zip(given).zip(&need) { + if !need { + *n = g; + } + } + } + if uvs.is_none() { + p.uvs.clear(); + } else { + // FBX's v axis points up the image; textures are stored top row first. + for uv in p.uvs.iter_mut() { + uv[1] = 1.0 - uv[1]; + } + } + if p.material.is_some_and(with_normal_maps) { + p.compute_tangents(); + } + mesh.primitives.push(p); + } + (!mesh.primitives.is_empty()).then_some(mesh) +} + +/// Lambert/Phong surface properties onto metallic-roughness. +fn material(name: &str, node: &FbxNode) -> Material { + let p = Props70::of(node); + let phong = node.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() + } +} + +/// Decode standard base64 (ASCII FBX embeds `Video` content this way); `None` on bad input. +fn base64(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + 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'+' => 62, + b'/' => 63, + b'=' => break, + c if c.is_ascii_whitespace() => continue, + _ => return None, + }; + acc = (acc << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +fn short_name(full: &str) -> &str { + full.split_once("::").map_or(full, |(_, n)| n) +} + +/// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. +pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { + let (version, doc) = read(bytes)?; + if version != 0 && version < 7000 { + return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); + } + let top = |name: &str| doc.iter().find(|n| n.name == name); + let objects = top("Objects").ok_or_else(|| perr("FBX: no Objects section"))?; + let by_id: HashMap = objects.children.iter().filter_map(|o| Some((o.prop(0)?.as_i64()?, o))).collect(); + let kind = |id: i64| by_id.get(&id).map(|n| n.name.as_str()); + + // Connections: (child, parent, property) in file order. + let mut links: Vec<(i64, i64, Option<&str>)> = vec![]; + if let Some(c) = top("Connections") { + for e in c.children_named("C") { + let (Some(a), Some(b)) = (e.prop(1).and_then(Prop::as_i64), e.prop(2).and_then(Prop::as_i64)) else { continue }; + links.push((a, b, e.str_at(3))); + } + } + + let model_ids: Vec = objects.children.iter().filter(|o| o.name == "Model").filter_map(|o| o.prop(0)?.as_i64()).collect(); + let mut node_of: HashMap = HashMap::new(); + let mut nodes = vec![Node { name: "FBX axes".into(), matrix: Some(axis_matrix(top("GlobalSettings"))), ..Node::default() }]; + for &id in &model_ids { + if node_of.contains_key(&id) { + continue; + } + let Some(o) = by_id.get(&id) else { continue }; + let name = short_name(o.str_at(1).unwrap_or("")).to_string(); + node_of.insert(id, nodes.len()); + nodes.push(Node { name, matrix: Some(model_local(&Props70::of(o))), ..Node::default() }); + } + + // Hierarchy: the first Model→Model link is the parent; anything else hangs off the 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 1..nodes.len() { + let p = parent.get(&i).copied().unwrap_or(0); + if let Some(n) = nodes.get_mut(p) { + n.children.push(i); + } + } + + // Materials (in first-use order) and their textures. + let mut materials: Vec = vec![]; + let mut textures: Vec> = vec![]; + let mut material_of: HashMap = HashMap::new(); + let mut texture_of: HashMap> = HashMap::new(); + let mut load_texture = |tid: i64, textures: &mut Vec>| -> Option { + if let Some(t) = texture_of.get(&tid) { + return *t; + } + let t = (|| { + let tex = by_id.get(&tid)?; + let embedded = links.iter().filter(|l| l.1 == tid && kind(l.0) == Some("Video")).find_map(|l| { + let content = by_id.get(&l.0)?.child("Content")?; + 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(&s).filter(|b| !b.is_empty()) + } + _ => None, + } + }); + let bytes = embedded.or_else(|| { + ["RelativeFilename", "FileName"].iter().find_map(|k| { + let f = tex.child(k)?.str_at(0)?.replace('\\', "/"); + resolve(&f).or_else(|| resolve(f.rsplit('/').next()?)) + }) + })?; + let t = Texture::decode(&bytes).ok()?; + textures.push(Arc::new(t)); + Some(textures.len() - 1) + })(); + texture_of.insert(tid, t); + t + }; + let mut material_index = |mid: i64, materials: &mut Vec, textures: &mut Vec>| -> Option { + if let Some(&m) = material_of.get(&mid) { + return Some(m); + } + let node = by_id.get(&mid)?; + let mut m = material(short_name(node.str_at(1).unwrap_or("")), node); + for &(tid, _, prop) in links.iter().filter(|l| l.1 == mid && kind(l.0) == Some("Texture")) { + let slot = match prop { + Some("DiffuseColor") => &mut m.base_color_tex, + Some("NormalMap") => &mut m.normal_tex, + Some("EmissiveColor") => &mut m.emissive_tex, + _ => continue, + }; + if slot.is_none() { + *slot = load_texture(tid, textures).map(|texture| TexRef { texture, uv_set: 0 }); + } + } + if m.emissive_tex.is_some() && m.emissive == [0.0; 3] { + m.emissive = [1.0; 3]; + } + materials.push(m); + material_of.insert(mid, materials.len() - 1); + Some(materials.len() - 1) + }; + + // One mesh per Model with Mesh geometry (material slots are per Model). + let mut meshes: Vec = vec![]; + for &id in &model_ids { + let Some(&ni) = node_of.get(&id) else { continue }; + let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; + let Some(geom) = by_id.get(&gid) else { continue }; + let mats: Vec = + links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + meshes.push(mesh); + let mesh_index = meshes.len() - 1; + let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); + match geo { + Some(matrix) => { + nodes.push(Node { name: format!("{name} geometry"), matrix: Some(matrix), mesh: Some(mesh_index), ..Node::default() }); + let gi = nodes.len() - 1; + if let Some(n) = nodes.get_mut(ni) { + n.children.push(gi); + } + } + None => { + if let Some(n) = nodes.get_mut(ni) { + n.mesh = Some(mesh_index); + } + } + } + } + if meshes.is_empty() { + return Err(perr("FBX file without meshes")); + } + Ok(Model { meshes, nodes, roots: vec![0], materials, textures, ..Model::default() }) +} + +#[cfg(test)] +#[path = "fbx_tests.rs"] +mod tests; diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs new file mode 100644 index 00000000..3cd8ba5a --- /dev/null +++ b/crates/model/src/fbx_tests.rs @@ -0,0 +1,327 @@ +use super::*; +use crate::load; + +/// 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. +pub(crate) const ASCII: &str = r#"; FBX 7.4.0 project file +; ---------------------------------------------------- +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 7400 + Creator: "EffectCraft 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 + } +} +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 check_scene(m: &Model) { + assert_eq!(m.meshes.len(), 1); + let names: Vec<&str> = m.nodes.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["FBX axes", "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].children, vec![3]); + assert_eq!(m.nodes[3].mesh, Some(0)); + + let prims = &m.meshes[0].primitives; + assert_eq!(prims.len(), 2); + let (quad, tri) = (&prims[0], &prims[1]); + assert_eq!((quad.positions.len(), quad.indices.clone()), (4, vec![0, 1, 2, 0, 2, 3])); + assert_eq!((tri.positions.len(), tri.triangle_count()), (3, 1)); + assert_eq!(quad.normals, vec![[0.0, 0.0, 1.0]; 4]); + assert_eq!(tri.normals, vec![[0.0, -1.0, 0.0]; 3]); + // UVs through the index, v flipped to image rows. + assert_eq!(quad.uvs, 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)); + + // Z-up → Y-up, parent translation, Z rotation, scale and the geometric offset. + let inst = m.instances(None, 0.0); + assert_eq!(inst.len(), 1); + let apex = inst[0].world.apply(vec3(0.5, 0.5, 1.0)); + assert!((apex.x - 9.0).abs() < 1e-9 && (apex.y - 4.0).abs() < 1e-9 && (apex.z + 1.0).abs() < 1e-9, "{apex:?}"); + let (lo, hi) = m.bounds().unwrap(); + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(lo, [8.0, 2.0, -2.0]) && close(hi, [10.0, 4.0, 0.0]), "{lo:?} {hi:?}"); +} + +/// A binary FBX of the same node tree (`a:` children become typed arrays). +fn write_binary(nodes: &[FbxNode], 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: &FbxNode, 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<&FbxNode> = 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 nodes { + 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_fbx_scene() { + let m = load("scene.fbx", ASCII.as_bytes(), &|_| None).unwrap(); + check_scene(&m); +} + +#[test] +fn binary_fbx_matches_ascii() { + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let ascii = parse(ASCII.as_bytes(), &|_| None).unwrap(); + for (version, compress) in [(7400, false), (7400, true), (7500, true)] { + let bin = write_binary(&doc, version, compress); + let (v, nodes) = read(&bin).unwrap(); + assert_eq!(v, version); + assert_eq!(nodes.iter().map(|n| n.name.as_str()).collect::>(), ["FBXHeaderExtension", "GlobalSettings", "Objects", "Connections"]); + // Found by its magic even without the extension. + let m = load("scene.bin", &bin, &|_| None).unwrap(); + assert_eq!(m, ascii, "version {version}, compressed {compress}"); + } +} + +#[test] +fn embedded_and_relative_textures() { + let png = { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let objects = r#" + Texture: 600, "Texture::Base", "" { + RelativeFilename: "maps\base.png" + } + Texture: 700, "Texture::Glow", "" { + RelativeFilename: "missing.png" + } + Video: 800, "Video::Glow", "Clip" { + Content: "BASE64" + } +} +Connections: {"# + .replace("BASE64", &b64(&png)); + let doc = ASCII.replace("}\nConnections: {", &objects).replace( + "C: \"OO\",500,300\n", + "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"EmissiveColor\"\n\tC: \"OO\",800,700\n", + ); + let m = load("scene.fbx", doc.as_bytes(), &|uri| (uri == "maps/base.png").then(|| png.clone())).unwrap(); + assert_eq!(m.textures.len(), 2); + let red = m.materials.iter().find(|x| x.name == "Red").unwrap(); + assert_eq!(m.textures[red.base_color_tex.unwrap().texture].data[0], [0.0, 1.0, 0.0, 1.0]); + let glass = m.materials.iter().find(|x| x.name == "Glass").unwrap(); + assert!(glass.emissive_tex.is_some()); + assert_eq!(glass.emissive, [1.0; 3]); +} + +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 { + if k <= c.len() { + s.push(A[(n >> (18 - 6 * k) & 63) as usize] as char); + } else { + s.push('='); + } + } + } + s +} + +#[test] +fn hostile_fbx_files_fail_cleanly() { + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let bin = write_binary(&doc, 7500, true); + // Every truncation and every single-byte corruption returns (no panic, no runaway). + for n in 0..bin.len() { + let _ = parse(&bin[..n], &|_| None); + } + for i in 0..bin.len() { + let mut b = bin.clone(); + b[i] ^= 0xff; + let _ = parse(&b, &|_| None); + } + // 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 start = huge.len(); + let end = start + 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!(read(&huge).is_err()); + // Deep nesting, both encodings. + assert!(read("A: {".repeat(100_000).as_bytes()).is_err()); + // Not an FBX scene at all, an old FBX version, and a scene without meshes. + assert!(parse(b"\x00\x01garbage", &|_| None).is_err()); + let v6 = "FBXHeaderExtension: {\n FBXVersion: 6100\n}\nObjects: {\n}\n"; + assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); + assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9480f29c..3b2a62b6 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -5,6 +5,8 @@ //! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and //! animations. //! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model). +//! - [`fbx`]: FBX 7.x scenes, binary and ASCII: meshes, the Model hierarchy, Lambert/Phong +//! materials and their textures. //! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder). //! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with //! [`triangulate`] for the caps. @@ -15,6 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod extrude; +pub mod fbx; pub mod gltf; pub mod obj; pub mod prim; @@ -720,13 +723,14 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64 [q.x, q.y, q.z] } -/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension. +/// Which loader handles a file name (`gltf`, `glb`, `obj`, `fbx`), by extension. pub fn format_of(path: &str) -> Option<&'static str> { let ext = path.rsplit('.').next()?.to_ascii_lowercase(); match ext.as_str() { "gltf" => Some("gltf"), "glb" => Some("glb"), "obj" => Some("obj"), + "fbx" => Some("fbx"), _ => None, } } @@ -736,9 +740,11 @@ pub fn format_of(path: &str) -> Option<&'static str> { pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { match format_of(path) { Some("obj") => obj::parse(bytes, resolve), + Some("fbx") => fbx::parse(bytes, resolve), Some(_) => gltf::parse(bytes, resolve), None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve), - None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))), + None if bytes.starts_with(fbx::BINARY_MAGIC) => fbx::parse(bytes, resolve), + None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ or FBX file"))), } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..e08126cd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1226,7 +1226,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option