Files
effectcraft/crates/model/src/fbx/tests.rs
T

519 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<u8> {
fn prop(out: &mut Vec<u8>, p: &Prop, compress: bool) {
let array = |out: &mut Vec<u8>, t: u8, len: usize, raw: Vec<u8>| {
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<u8>, 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::<Vec<_>>(), ["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<u8> = 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());
}
/// A version-7400 binary FBX holding one top-level node `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<u8> {
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 fbx_arrays_are_charged_for_the_memory_they_keep() {
// A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`:
// the budget counts the eight (#1234).
let n = 10_000;
let bin = binary_arrays(&[(b'b', n)]);
assert!(read_within(&bin, &Budget::new(n * 8)).is_ok());
let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err();
assert!(e.to_string().contains("memory limit"), "{e}");
// 32-bit floats widen to f64 the same way.
assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err());
// Several arrays each within the budget but not together.
let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]);
assert!(read_within(&bin, &Budget::new(n * 24)).is_ok());
assert!(read_within(&bin, &Budget::new(n * 20)).is_err());
// ASCII values count too.
let (_, doc) = read(ASCII.as_bytes()).unwrap();
let values = value_bytes(&doc).unwrap();
assert!(values > 45 * std::mem::size_of::<Prop>(), "{values}");
assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().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 {
ASCII
.replace(
"\tMaterial: 400,",
"\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,",
)
.replace(
"\tC: \"OO\",500,300\n",
"\tC: \"OO\",500,300\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 fbx_models_instancing_one_geometry_share_its_mesh() {
let m = parse(instanced_scene().as_bytes(), &|_| None).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);
// A budget with room for one copy of the geometry's mesh, but not three, still imports.
let one = parse(ASCII.as_bytes(), &|_| None).unwrap();
let mesh = mesh_bytes(&one.meshes[0]).unwrap();
let (_, doc) = read(instanced_scene().as_bytes()).unwrap();
let values = value_bytes(&doc).unwrap();
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok());
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err());
}
#[test]
fn binary_child_record_past_its_parent_is_an_error() {
let parent = FbxNode { name: "P".into(), props: vec![], children: vec![FbxNode { name: "C".into(), props: vec![], children: vec![] }] };
let after = FbxNode { name: "Q".into(), props: vec![Prop::Int(1)], children: vec![] };
let mut bin = write_binary(&[parent, after], 7400, false);
assert_eq!(read(&bin).unwrap().1.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!(read(&bin).unwrap_err().to_string().contains("bad record end"));
}
#[test]
fn texture_aliases_of_one_image_share_its_pixels() {
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::Base again", "" {
RelativeFilename: "maps\base.png"
}
}
Connections: {"#;
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, \"DiffuseColor\"\n");
let resolve = |uri: &str| (uri == "maps/base.png").then(|| png.clone());
let m = parse(doc.as_bytes(), &resolve).unwrap();
assert_eq!(m.textures.len(), 1);
assert!(m.materials.iter().all(|x| x.base_color_tex.map(|t| t.texture) == Some(0)));
// Decoded pixels count against the import's memory budget (the two pixels take 32 bytes).
let (_, nodes) = read(doc.as_bytes()).unwrap();
let values = value_bytes(&nodes).unwrap();
let mesh = mesh_bytes(&m.meshes[0]).unwrap();
assert_eq!(parse_within(doc.as_bytes(), &resolve, values + mesh + 32).unwrap().textures.len(), 1);
assert!(parse_within(doc.as_bytes(), &resolve, values + mesh + 31).unwrap_err().to_string().contains("memory limit"));
}
/// A PNG that is only a header claiming `w`×`h` pixels (an empty data chunk follows).
fn png_header_only(w: u32, h: u32) -> Vec<u8> {
fn chunk(png: &mut Vec<u8>, ty: &[u8], data: &[u8]) {
let body = [ty, data].concat();
let mut crc = 0xFFFF_FFFFu32;
for &b in &body {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
png.extend((data.len() as u32).to_be_bytes());
png.extend(&body);
png.extend((!crc).to_be_bytes());
}
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
chunk(&mut png, b"IHDR", &[w.to_be_bytes(), h.to_be_bytes(), [8, 6, 0, 0]].concat().iter().copied().chain([0]).collect::<Vec<u8>>());
chunk(&mut png, b"IDAT", &[]);
png
}
#[test]
fn texture_budget_is_charged_before_decoding_and_its_error_propagates() {
let objects = r#"
Texture: 600, "Texture::Base", "" {
RelativeFilename: "maps\base.png"
}
}
Connections: {"#;
let doc = ASCII.replace("}\nConnections: {", objects).replace("C: \"OO\",500,300\n", "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n");
// 30000×30000 pixels would take ~14 GiB; the header alone must be refused against the budget.
let huge = png_header_only(30000, 30000);
let resolve = |_: &str| Some(huge.clone());
assert!(parse(doc.as_bytes(), &resolve).unwrap_err().to_string().contains("memory limit"));
}
#[test]
fn texture_files_resolving_to_one_image_share_it() {
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::Other spelling", "" {
RelativeFilename: "missing\base.png"
FileName: "maps\base.png"
}
}
Connections: {"#;
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, \"DiffuseColor\"\n");
let resolve = |uri: &str| (uri == "maps/base.png").then(|| png.clone());
assert_eq!(parse(doc.as_bytes(), &resolve).unwrap().textures.len(), 1);
}