mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Problem: After Effects 26 imports FBX models into Advanced 3D comps; File > Import rejected .fbx, so FBX product shots and scenes could not be used. Fix: a clean-room FBX 7.x reader in effectcraft-model (binary node records with 32/64-bit offsets and zlib arrays, ASCII syntax) and a scene builder: meshes with normals, UVs and per-polygon materials, the Model hierarchy with the SDK transform (pivots, offsets, pre/post rotation, rotation order, geometric transform), Lambert/Phong materials onto metallic-roughness with embedded or relative textures, and GlobalSettings axes turned to Y-up. Wired into import, probing, the media browser and the file dialog. Hostile input (truncation, corruption, huge arrays, deep nesting) fails with an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
12 KiB
Rust
328 lines
12 KiB
Rust
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());
|
|
}
|