mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -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>
876 lines
34 KiB
Rust
876 lines
34 KiB
Rust
//! 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<u8>),
|
|
Ints(Vec<i64>),
|
|
Floats(Vec<f64>),
|
|
}
|
|
|
|
impl Prop {
|
|
fn as_f64(&self) -> Option<f64> {
|
|
match self {
|
|
Prop::Int(i) => Some(*i as f64),
|
|
Prop::Float(f) => Some(*f),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn as_i64(&self) -> Option<i64> {
|
|
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<Prop>,
|
|
pub children: Vec<FbxNode>,
|
|
}
|
|
|
|
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<Item = &'a FbxNode> + '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<f64> {
|
|
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<i64> {
|
|
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<FbxNode>)> {
|
|
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<const N: usize>(&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<u32> {
|
|
Ok(u32::from_le_bytes(self.arr(pos)?))
|
|
}
|
|
fn offset(&self, pos: &mut usize) -> Result<u64> {
|
|
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> {
|
|
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<Vec<FbxNode>> {
|
|
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<Option<FbxNode>> {
|
|
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<Prop> {
|
|
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<FbxNode>)> {
|
|
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<Tok> {
|
|
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::<i64>() {
|
|
out.push(Tok::Val(Prop::Int(i)));
|
|
} else if let Ok(f) = word.parse::<f64>() {
|
|
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<Vec<FbxNode>> {
|
|
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<FbxNode>)> {
|
|
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<f64> {
|
|
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<f64> {
|
|
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<Mat4> {
|
|
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<f64>,
|
|
index: Option<Vec<i64>>,
|
|
width: usize,
|
|
}
|
|
|
|
impl LayerData {
|
|
fn of(geom: &FbxNode, element: &str, data: &str, index: &str, width: usize) -> Option<LayerData> {
|
|
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<const N: usize>(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<Mesh> {
|
|
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<usize>, Primitive, HashMap<Key, u32>, Vec<bool>)> = 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<Vec<u8>> {
|
|
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<Vec<u8>>) -> Result<Model> {
|
|
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<i64, &FbxNode> = 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<i64> = objects.children.iter().filter(|o| o.name == "Model").filter_map(|o| o.prop(0)?.as_i64()).collect();
|
|
let mut node_of: HashMap<i64, usize> = 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<usize, usize> = 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<Material> = vec![];
|
|
let mut textures: Vec<Arc<Texture>> = vec![];
|
|
let mut material_of: HashMap<i64, usize> = HashMap::new();
|
|
let mut texture_of: HashMap<i64, Option<usize>> = HashMap::new();
|
|
let mut load_texture = |tid: i64, textures: &mut Vec<Arc<Texture>>| -> Option<usize> {
|
|
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<Material>, textures: &mut Vec<Arc<Texture>>| -> Option<usize> {
|
|
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<Mesh> = 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<usize> =
|
|
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<bool> = 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;
|