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