craft-mesh: FBX reader (6.x and 7.x, binary and ASCII) (#906)

* craft-mesh: FBX 7.x reader from EffectCraft (`fbx`)

EffectCraft's FBX reader (crates/model/src/fbx.rs: binary with 32- and 64-bit
record offsets and zlib arrays, ASCII, geometry layer elements, the Model
transform chain, Lambert/Phong materials, textures) onto the crate's Scene.
The reader was written by vangel76 (EffectCraft #736); its memory budget and
shared instanced meshes by DWAA1660 (EffectCraft #1234).

It includes the fixes of the open EffectCraft PR #2955: a binary child record
must end within its parent, a failed array inflate reports only its status,
textures that end at one image share it and its bytes are charged to the
budget, and material slots and connections are found through maps.

What differs from the app's copy, because the model here is neutral:
polygons stay whole and layer elements stay indexed pools (no vertex
unification, no computed normals or tangents); images are bytes, not decoded
pixels; the axis system is not applied (Document::axes_to_y_up gives it);
the memory budget is Limits::max_data_bytes; a file without meshes is a
scene, not an error. base64_decode moves from the gltf module to the crate
root so both formats share it (gltf re-exports it).

* craft-mesh: read FBX 6.x

FBX 6.1 files differ from 7.x in how the scene is laid out, not in the
record syntax: a Model holds its own Vertices and layer elements, properties
are Properties60/Property (no label field), Connect lines name objects by
Class::Name instead of by id, and GlobalSettings sits among the objects.
The reader now takes all four, and NormalIndex beside NormalsIndex, so 6.x
geometry, transforms, the hierarchy and materials are read. 6.x textures
(connected to Models) are not.

Ported from storytold/deckcraft#1380 (crates/mesh/src/fbx), whose reader
took 6.1 to 7.7; its mesh building, axis turn and rasteriser stay in
DeckCraft.

---------

Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
This commit is contained in:
Dylan M. TaylorandEthan Dale authored and GitHub committed 2026-11-11 17:35:28 -05:00
1 parent 2f8f9ad8c9
commit 2144a8c48c
9 files changed
+1852 -39

No files matched your search

Generated
+1
View File
@@ -1116,6 +1116,7 @@ dependencies = [
name = "craft-mesh"
version = "0.1.0"
dependencies = [
"miniz_oxide 0.9.1",
"serde_json",
]
+2 -2
View File
@@ -149,8 +149,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
fed back to egui as the input egui-winit would have made. Commands and their enabled state
stay in each app.
- [`craft-mesh`](crates/mesh/README.md): 3D mesh file formats behind one neutral model (meshes
of polygons with per-corner indices, materials, a node hierarchy): STL, OBJ with MTL, PLY and
glTF 2.0 / GLB, one feature each. Readers are bounded by `Limits`, never open a file themselves
of polygons with per-corner indices, materials, a node hierarchy): STL, OBJ with MTL, PLY,
glTF 2.0 / GLB and FBX, one feature each. Readers are bounded by `Limits`, never open a file themselves
and never panic. Carved from EffectCraft, SolidCraft, TextureCraft and MaterialCraft.
- [`craft-midi`](crates/midi/README.md): Standard MIDI Files: a bounded reader for formats 0, 1 and
2 and a format-1 writer, over a neutral model of notes and channel events in ticks at 960 per
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-mesh"
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers, bounded on hostile input"
description = "3D mesh file formats behind one neutral model: STL, Wavefront OBJ/MTL, PLY and glTF 2.0 / GLB readers and writers and an FBX reader, bounded on hostile input"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -11,11 +11,14 @@ repository.workspace = true
workspace = true
[features]
default = ["stl", "obj", "ply", "gltf"]
default = ["stl", "obj", "ply", "gltf", "fbx"]
stl = []
obj = []
ply = []
gltf = ["dep:serde_json"]
fbx = ["dep:miniz_oxide"]
[dependencies]
serde_json = { workspace = true, optional = true }
# zlib-compressed arrays in binary FBX.
miniz_oxide = { version = "0.9", optional = true }
+24 -7
View File
@@ -9,8 +9,9 @@ a `Scene` into bytes. One Cargo feature per format, all on by default.
| `obj` | Wavefront OBJ with MTL | yes | OBJ and MTL | none |
| `ply` | PLY, ASCII and both binary byte orders | yes | no (no app writes PLY) | none |
| `gltf` | glTF 2.0 (`.gltf`) and GLB | yes | GLB | `serde_json` |
| `fbx` | FBX 6.x and 7.x, binary and ASCII | yes | no | `miniz_oxide` |
FBX, USD, Alembic, COLLADA and 3MF are not here yet.
USD, Alembic, COLLADA and 3MF are not here yet.
## The neutral model
@@ -78,7 +79,21 @@ cameras; `KHR_lights_punctual`. Primitives of a mesh that name the same accessor
vertices. `write_glb` writes nodes, meshes (positions, normals, UV sets, colours), materials and
images.
Not read: sparse accessors and required geometry extensions (Draco, meshopt) are
**FBX.** Binary files (32-bit record offsets before 7.5, 64-bit from it; every property type;
arrays plain or zlib-compressed) and ASCII files give the same record tree, which
`fbx::read_document` returns as it is. From it: `Geometry` meshes with whole polygons and the
first normal, UV and material layer elements in every mapping and reference mode (kept as
indexed pools); `Model` nodes with the full transform (offsets, pivots, pre- and post-rotation,
every rotation order) and geometric transforms; Lambert and Phong `Material`s mapped onto
metallic-roughness; `Texture`s with embedded `Video` content or file names, for the diffuse,
normal and emissive slots. Models that instance one geometry with the same materials share a
mesh, and textures that end at one image share it. `Document::axes_to_y_up` and
`Document::unit_scale` give the file's axis system and unit, which the scene does not apply.
FBX 6.x files (a `Model` holds its mesh, `Properties60`, objects connected by name) give
geometry, transforms, the hierarchy and materials. Not read: FBX 5 and older, 6.x textures,
animation, skin deformers, blend shapes, cameras, lights, NURBS.
Not read (glTF): sparse accessors and required geometry extensions (Draco, meshopt) are
`Error::Unsupported`; points, lines, morph targets, tangents and other extensions are passed over.
Not written: skins, animations, cameras, lights, and `.gltf` with separate files.
@@ -94,20 +109,22 @@ mutations of every byte of good files.
| `max_vertices` | 20 000 000 | entries per attribute pool (OBJ: each of `v`, `vt`, `vn`, counting unused ones; glTF: vertices over all meshes) |
| `max_triangles` | 20 000 000 | triangles after fanning polygons, strips and fans |
| `max_face_corners` | 4096 | corners of one face; PLY list lengths |
| `max_meshes` | 100 000 | OBJ objects, glTF meshes |
| `max_parts` | 100 000 | groups, material runs, glTF primitives |
| `max_meshes` | 100 000 | OBJ objects, glTF meshes, FBX meshes |
| `max_parts` | 100 000 | groups, material runs, glTF primitives, material slots of an FBX mesh |
| `max_materials` | 100 000 | materials; images; OBJ material libraries |
| `max_nodes` | 100 000 | nodes, and node visits when the hierarchy is walked (ends cycles) |
| `max_depth` | 64 | node nesting |
| `max_data_bytes` | 1 GiB | buffers and material libraries taken from the resolver; embedded image bytes; skin and animation values; the binary chunk `write_glb` builds |
| `max_data_bytes` | 1 GiB | buffers and material libraries taken from the resolver; embedded image bytes; skin and animation values; the binary chunk `write_glb` builds; everything an FBX file keeps (parsed values at 8 bytes per array element, meshes, image bytes) |
| `max_extras` | 10 000 | skins, animations, cameras, lights, buffers (each); channels of one animation |
Fixed bounds: a PLY header of 1 MiB, 64 elements and 256 properties per element; 2^24 components
for glTF accessors without a buffer view, over the whole file.
for glTF accessors without a buffer view, over the whole file; FBX records nested 64 deep and
arrays of 1 GiB.
**External files.** No reader opens a file. A reader that needs another file to parse (an OBJ
material library, a glTF buffer) asks the caller's `Resolver` for it by the name the file writes;
the resolver may decline (`None`). Images are never asked for: they are named in `Image::uri`.
the resolver may decline (`None`). Images are named in `Image::uri` and not asked for, except by
the FBX reader, whose textures carry two file names that only it can try in turn.
Every name is listed in `Scene::external`. Names come from the file, so a resolver must not trust
them as paths: they may be absolute or contain `..`.
File diff suppressed because it is too large. Load diff
+1 -26
View File
@@ -109,32 +109,7 @@ pub fn glb_container(json: &[u8], bin: &[u8]) -> Result<Vec<u8>> {
Ok(out)
}
/// Decode standard or URL-safe base64 (with or without padding; whitespace ignored).
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3);
let mut acc = 0u32;
let mut bits = 0;
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'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' => break,
b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return None,
};
acc = (acc << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
pub use crate::base64_decode;
/// Percent-decode a URI. A `%` that two hex digits do not follow stays as it is.
pub fn uri_decode(s: &str) -> String {
+31 -2
View File
@@ -1,7 +1,7 @@
//! 3D mesh file formats behind one neutral model.
//!
//! Each format is a module behind a Cargo feature of the same name: [`stl`], [`obj`] (with MTL),
//! [`ply`] and [`gltf`] (glTF 2.0 and GLB). A reader turns bytes into a [`Scene`]; a writer turns
//! [`ply`], [`gltf`] (glTF 2.0 and GLB) and [`fbx`]. A reader turns bytes into a [`Scene`]; a writer turns
//! a [`Scene`] into bytes. Mapping a [`Scene`] onto an application's own mesh, body or document
//! types is the caller's business, as are computing missing normals, tangents and UVs, welding,
//! unit scaling and turning a file's up axis.
@@ -28,8 +28,10 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// The helpers the formats share are not all used by every subset of them.
#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf")), allow(dead_code, unused_imports))]
#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf", feature = "fbx")), allow(dead_code, unused_imports))]
#[cfg(feature = "fbx")]
pub mod fbx;
#[cfg(feature = "gltf")]
pub mod gltf;
#[cfg(feature = "obj")]
@@ -691,6 +693,33 @@ pub fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}
/// Decode standard or URL-safe base64 (with or without padding; whitespace ignored).
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3);
let mut acc = 0u32;
let mut bits = 0;
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'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' => break,
b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return None,
};
acc = (acc << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
/// Warnings that would repeat are counted and reported once.
pub(crate) fn count_warning(warnings: &mut Vec<String>, count: usize, what: &str) {
if count > 0 {
+2
View File
@@ -2,6 +2,8 @@
use super::*;
#[cfg(feature = "fbx")]
mod fbx;
#[cfg(feature = "gltf")]
mod gltf;
#[cfg(feature = "obj")]
+629
View File
@@ -0,0 +1,629 @@
//! FBX: the same scene as ASCII and as every binary layout, textures, limits and hostile files.
use super::hammer;
use crate::fbx::{BINARY_MAGIC, Document, Prop, Record, is_binary, mesh_bytes, read, read_document, value_bytes};
use crate::{AlphaMode, Corner, Error, Limits, NONE, Scene, mat_mul, no_external, transform_point};
/// 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.
const ASCII: &str = r#"; FBX 7.4.0 project file
; ----------------------------------------------------
FBXHeaderExtension: {
FBXHeaderVersion: 1003
FBXVersion: 7400
Creator: "craft-mesh 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
P: "UnitScaleFactor", "double", "Number", "",2.54
}
}
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 parse(bytes: &[u8]) -> crate::Result<Scene> {
read(bytes, &Limits::default(), &mut no_external)
}
fn within(bytes: &[u8], max_data_bytes: usize) -> crate::Result<Scene> {
read(bytes, &Limits { max_data_bytes, ..Limits::default() }, &mut no_external)
}
fn document(bytes: &[u8]) -> Document {
read_document(bytes, &Limits::default()).unwrap()
}
fn check_scene(doc: &Document, m: &Scene) {
m.validate().unwrap();
assert_eq!(m.meshes.len(), 1);
let names: Vec<&str> = m.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["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].mesh, Some(0));
let mesh = &m.meshes[0];
assert_eq!((mesh.name.as_str(), mesh.positions.len(), mesh.parts.len()), ("Shape", 5, 2));
let (quad, tri) = (&mesh.parts[0], &mesh.parts[1]);
// The quad stays whole; a normal per polygon; UVs through the index.
assert_eq!(quad.face_sizes, [4]);
assert_eq!(quad.corners, [0, 1, 2, 3].map(|i| Corner { position: i, normal: 0, uv: i }));
assert!(tri.face_sizes.is_empty());
assert_eq!(tri.corners, [(0, 0), (1, 1), (4, 2)].map(|(p, uv)| Corner { position: p, normal: 1, uv }));
assert_eq!(mesh.normals, [[0.0, 0.0, 1.0], [0.0, -1.0, 0.0]]);
// v flipped to image rows.
assert_eq!(mesh.uv_sets, [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));
// Parent translation, Z rotation, scale and the geometric offset; then Z-up → Y-up, which
// the scene leaves to the caller.
let inst = m.instances(&Limits::default()).unwrap();
assert_eq!(inst.len(), 1);
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9);
let apex = transform_point(&inst[0].1, [0.5, 0.5, 1.0]);
assert!(close(apex, [9.0, 1.0, 4.0]), "{apex:?}");
let apex = transform_point(&mat_mul(&doc.axes_to_y_up(), &inst[0].1), [0.5, 0.5, 1.0]);
assert!(close(apex, [9.0, 4.0, -1.0]), "{apex:?}");
assert_eq!(doc.unit_scale(), Some(2.54));
}
/// A binary FBX of the same record tree (`a:` children become typed arrays).
fn write_binary(records: &[Record], 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: &Record, 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<&Record> = 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 records {
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_scene() {
assert!(!is_binary(ASCII.as_bytes()));
check_scene(&document(ASCII.as_bytes()), &parse(ASCII.as_bytes()).unwrap());
}
#[test]
fn binary_matches_ascii() {
let doc = document(ASCII.as_bytes());
let ascii = parse(ASCII.as_bytes()).unwrap();
for (version, compress) in [(7400, false), (7400, true), (7500, true), (6100, false)] {
let bin = write_binary(&doc.records, version, compress);
assert!(is_binary(&bin));
let d = document(&bin);
assert_eq!(d.version, version);
assert_eq!(d.records.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["FBXHeaderExtension", "GlobalSettings", "Objects", "Connections"]);
let m = parse(&bin).unwrap();
check_scene(&d, &m);
assert_eq!(m, ascii, "version {version}, compressed {compress}");
}
}
/// [`ASCII`] with more objects before the end of `Objects` and more connections.
fn with(objects: &str, connections: &str) -> String {
ASCII
.replace("}\nConnections: {", &format!("{objects}}}\nConnections: {{"))
.replace("C: \"OO\",500,300\n", &format!("C: \"OO\",500,300\n{connections}"))
}
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 {
s.push(if k <= c.len() { A[(n >> (18 - 6 * k) & 63) as usize] as char } else { '=' });
}
}
s
}
#[test]
fn embedded_named_and_supplied_textures() {
let text = with(
&format!(
"\tTexture: 600, \"Texture::Base\", \"\" {{\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}}\n\
\tTexture: 700, \"Texture::Glow\", \"\" {{\n\t\tRelativeFilename: \"missing.png\"\n\t}}\n\
\tTexture: 900, \"Texture::Bumps\", \"\" {{\n\t\tFileName: \"C:\\art\\bumps.png\"\n\t}}\n\
\tVideo: 800, \"Video::Glow\", \"Clip\" {{\n\t\tContent: \"{}\"\n\t}}\n",
b64(b"embedded image")
),
"\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"EmissiveColor\"\n\tC: \"OO\",800,700\n\tC: \"OP\",900,400, \"NormalMap\"\n",
);
let mut asked = vec![];
let m = read(text.as_bytes(), &Limits::default(), &mut |uri: &str| {
asked.push(uri.to_string());
(uri == "maps/base.png").then(|| b"file image".to_vec())
})
.unwrap();
m.validate().unwrap();
let image = |t: Option<crate::TextureRef>| &m.images[t.unwrap().image];
let red = m.materials.iter().find(|x| x.name == "Red").unwrap();
let base = image(red.base_color_texture);
assert_eq!((base.name.as_str(), base.uri.as_deref(), base.data.as_deref()), ("Base", Some("maps/base.png"), Some(&b"file image"[..])));
// Declined (as written, then by its last component): named only.
let bumps = image(red.normal_texture);
assert_eq!((bumps.uri.as_deref(), &bumps.data), (Some("C:/art/bumps.png"), &None));
// Embedded content wins over the file, and an emissive texture lights the material.
let glass = m.materials.iter().find(|x| x.name == "Glass").unwrap();
let glow = image(glass.emissive_texture);
assert_eq!((glow.uri.as_deref(), glow.data.as_deref()), (Some("missing.png"), Some(&b"embedded image"[..])));
assert_eq!(glass.emissive, [1.0; 3]);
assert_eq!(m.images.len(), 3);
assert_eq!(asked, ["maps/base.png", "C:/art/bumps.png", "bumps.png"]);
assert_eq!(m.external, ["maps/base.png", "C:/art/bumps.png", "missing.png"]);
// The same embedded content in a binary file is raw bytes.
let mut doc = document(text.as_bytes());
let objects = doc.records.iter_mut().find(|r| r.name == "Objects").unwrap();
let video = objects.children.iter_mut().find(|r| r.name == "Video").unwrap();
video.children[0].props = vec![Prop::Bytes(b"embedded image".to_vec())];
let bin = parse(&write_binary(&doc.records, 7400, true)).unwrap();
assert!(bin.images.iter().any(|i| i.data.as_deref() == Some(&b"embedded image"[..])));
}
#[test]
fn textures_that_end_at_one_image_share_it() {
// Two Texture objects naming one file; a third reaching it through its second name.
let text = with(
"\tTexture: 600, \"Texture::Base\", \"\" {\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}\n\
\tTexture: 700, \"Texture::Base again\", \"\" {\n\t\tRelativeFilename: \"maps\\base.png\"\n\t}\n\
\tTexture: 710, \"Texture::Other spelling\", \"\" {\n\t\tRelativeFilename: \"missing\\base.png\"\n\t\tFileName: \"maps\\base.png\"\n\t}\n",
"\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"DiffuseColor\"\n\tC: \"OP\",710,500, \"EmissiveColor\"\n",
);
let image = b"twelve bytes".to_vec();
let supply = |limit: usize| {
let limits = Limits { max_data_bytes: limit, ..Limits::default() };
read(text.as_bytes(), &limits, &mut |uri: &str| (uri == "maps/base.png").then(|| image.clone()))
};
let m = supply(1 << 30).unwrap();
assert_eq!(m.images.len(), 1);
assert!(m.materials.iter().all(|x| x.base_color_texture.map(|t| t.image) == Some(0)));
assert_eq!(m.materials[1].emissive_texture.map(|t| t.image), Some(0));
// The image's bytes count against the memory limit, once.
let values = value_bytes(&document(text.as_bytes()).records).unwrap();
let mesh = mesh_bytes(&m.meshes[0]).unwrap();
assert_eq!(supply(values + mesh + image.len()).unwrap().images.len(), 1);
assert!(matches!(supply(values + mesh + image.len() - 1), Err(Error::TooLarge(e)) if e.contains("memory limit")));
}
#[test]
fn hostile_files_fail_cleanly() {
let doc = document(ASCII.as_bytes());
// Every truncation and two corruptions of every byte return (no panic, no runaway), and
// what is accepted is a valid scene.
hammer(&write_binary(&doc.records, 7500, true), &parse);
hammer(&write_binary(&doc.records, 7400, false), &parse);
hammer(ASCII.as_bytes(), &parse);
// 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 end = huge.len() + 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!(matches!(read_document(&huge, &Limits::default()), Err(Error::TooLarge(_))));
// Deep nesting, both encodings.
assert!(matches!(read_document("A: {".repeat(100_000).as_bytes(), &Limits::default()), Err(Error::TooLarge(_))));
let mut deep = Record { name: "Leaf".into(), ..Record::default() };
for _ in 0..200 {
deep = Record { name: "N".into(), props: vec![], children: vec![deep] };
}
assert!(matches!(read_document(&write_binary(&[deep], 7400, false), &Limits::default()), Err(Error::TooLarge(_))));
// Not an FBX scene at all, and an FBX version that is not read.
assert!(matches!(parse(b"\x00\x01garbage"), Err(Error::Malformed(_))));
assert!(parse(b"Kaydara FBX Binary \0\x1a\0").is_err());
let v5 = "FBXHeaderExtension: {\n FBXVersion: 5800\n}\nObjects: {\n}\n";
hammer(FBX6.as_bytes(), &parse);
hammer(&write_binary(&document(FBX6.as_bytes()).records, 6100, false), &parse);
assert!(matches!(parse(v5.as_bytes()), Err(Error::Unsupported(_))));
// A scene without meshes is a scene.
let empty = parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n").unwrap();
assert_eq!((empty.nodes.len(), empty.roots.as_slice(), empty.meshes.len()), (1, &[0][..], 0));
// Polygons that name a vertex that is not there, or have two corners, are left out; layer
// indices past their arrays give corners without a normal.
let text = "Objects: {\n Geometry: 1, \"Geometry::G\", \"Mesh\" {\n Vertices: 0,0,0,1,0,0,0,1,0\n PolygonVertexIndex: 0,1,99,-3,0,-2,0,1,-3\n \
LayerElementNormal: 0 {\n MappingInformationType: \"ByVertice\"\n ReferenceInformationType: \"IndexToDirect\"\n Normals: 0,0,1\n NormalsIndex: 0,7,-1\n }\n }\n \
Model: 2, \"Model::M\", \"Mesh\" {\n }\n}\nConnections: {\n C: \"OO\",1,2\n C: \"OO\",2,2\n}\n";
let m = parse(text.as_bytes()).unwrap();
m.validate().unwrap();
assert_eq!(m.meshes[0].parts[0].corners, [Corner { position: 0, normal: 0, uv: NONE }, Corner::at(1), Corner::at(2)]);
assert_eq!(m.warnings.len(), 1);
assert!(m.warnings[0].starts_with("2 polygons"), "{:?}", m.warnings);
}
/// A version-7400 binary FBX holding one top-level record `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 arrays_are_charged_for_the_memory_they_keep() {
let doc = |bin: &[u8], max_data_bytes: usize| read_document(bin, &Limits { max_data_bytes, ..Limits::default() });
// A boolean array is one byte per element in the file but eight once kept as `Prop::Ints`:
// the limit counts the eight.
let n = 10_000;
let bin = binary_arrays(&[(b'b', n)]);
assert!(doc(&bin, n * 8).is_ok());
assert!(matches!(doc(&bin, n * 8 - 1), Err(Error::TooLarge(e)) if e.contains("memory limit")));
// 32-bit floats widen to f64 the same way.
assert!(doc(&binary_arrays(&[(b'f', n)]), n * 4).is_err());
// Several arrays each within the limit but not together.
let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]);
assert!(doc(&bin, n * 24).is_ok());
assert!(doc(&bin, n * 20).is_err());
// ASCII values count too.
let values = value_bytes(&document(ASCII.as_bytes()).records).unwrap();
assert!(values > 45 * std::mem::size_of::<Prop>(), "{values}");
assert!(matches!(within(ASCII.as_bytes(), values - 1), Err(Error::TooLarge(e)) if e.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 {
with(
"\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\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 models_instancing_one_geometry_share_its_mesh() {
let text = instanced_scene();
let m = parse(text.as_bytes()).unwrap();
m.validate().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);
assert_eq!(m.roots.len(), 3);
// A limit with room for the two meshes, but not a third, still reads.
let meshes: usize = m.meshes.iter().map(|m| mesh_bytes(m).unwrap()).sum();
let values = value_bytes(&document(text.as_bytes()).records).unwrap();
assert!(within(text.as_bytes(), values + meshes).is_ok());
assert!(within(text.as_bytes(), values + meshes - 1).is_err());
}
#[test]
fn binary_child_record_past_its_parent_is_an_error() {
let parent = Record { name: "P".into(), props: vec![], children: vec![Record { name: "C".into(), ..Record::default() }] };
let after = Record { name: "Q".into(), props: vec![Prop::Int(1)], children: vec![] };
let mut bin = write_binary(&[parent, after], 7400, false);
assert_eq!(document(&bin).records.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!(matches!(read_document(&bin, &Limits::default()), Err(Error::Malformed(e)) if e.contains("bad record end")));
}
#[test]
fn a_failed_array_inflate_reports_only_its_status() {
let doc = document(ASCII.as_bytes());
let mut bin = write_binary(&doc.records, 7400, true);
// Break the zlib header of the first compressed array (`Vertices`: type, count, encoding 1,
// length, then the stream).
let at = bin.windows(8).position(|w| w == b"Vertices").unwrap() + 8 + 13;
bin[at] = 0xff;
let Err(Error::Malformed(e)) = read_document(&bin, &Limits::default()) else { panic!("read a broken array") };
assert!(e.contains("array zlib") && e.len() < 80, "{e}");
}
#[test]
fn every_limit_is_kept() {
let text = instanced_scene();
let refused = |limits: Limits| matches!(read(text.as_bytes(), &limits, &mut no_external), Err(Error::TooLarge(_)));
let d = Limits::default;
// 5 vertices; 3 triangles in each of 2 meshes; a quad; 2 material slots; 2 materials;
// 4 Models and 3 geometry nodes; 2 meshes.
for (at, under) in [
(Limits { max_vertices: 5, ..d() }, Limits { max_vertices: 4, ..d() }),
(Limits { max_triangles: 6, ..d() }, Limits { max_triangles: 5, ..d() }),
(Limits { max_face_corners: 4, ..d() }, Limits { max_face_corners: 3, ..d() }),
(Limits { max_parts: 2, ..d() }, Limits { max_parts: 1, ..d() }),
(Limits { max_materials: 2, ..d() }, Limits { max_materials: 1, ..d() }),
(Limits { max_nodes: 5, ..d() }, Limits { max_nodes: 4, ..d() }),
(Limits { max_meshes: 2, ..d() }, Limits { max_meshes: 1, ..d() }),
] {
assert!(!refused(at.clone()), "{at:?}");
assert!(refused(under.clone()), "{under:?}");
}
// Images share the materials' limit.
let textured = with(
"\tTexture: 600, \"Texture::A\", \"\" {\n\t\tFileName: \"a.png\"\n\t}\n\tTexture: 700, \"Texture::B\", \"\" {\n\t\tFileName: \"b.png\"\n\t}\n\tMaterial: 510, \"Material::Third\", \"\" {\n\t}\n",
"\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,400, \"NormalMap\"\n\tC: \"OO\",510,300\n",
);
let images = |max_materials: usize| read(textured.as_bytes(), &Limits { max_materials, ..d() }, &mut no_external);
assert_eq!(images(3).unwrap().images.len(), 2);
assert!(matches!(images(1), Err(Error::TooLarge(_))));
}
#[test]
fn axes_that_are_not_a_turn_are_left_alone() {
let both_on_z = ASCII.replace("\"FrontAxis\", \"int\", \"Integer\", \"\",1", "\"FrontAxis\", \"int\", \"Integer\", \"\",2");
assert_eq!(document(both_on_z.as_bytes()).axes_to_y_up(), crate::IDENTITY);
let no_settings = document(b"Objects: {\n}\n");
assert_eq!((no_settings.axes_to_y_up(), no_settings.unit_scale()), (crate::IDENTITY, None));
}
/// FBX 6.1 as Blender's exporter wrote it: the Model holds the mesh, `Properties60`, objects
/// named in `Connect` lines, `GlobalSettings` among the objects.
const FBX6: &str = r#"; FBX 6.1.0 project file
FBXHeaderExtension: {
FBXHeaderVersion: 1003
FBXVersion: 6100
}
Objects: {
Model: "Model::Root", "Null" {
Properties60: {
Property: "Lcl Scaling", "Lcl Scaling", "A+",2,2,2
}
}
Model: "Model::Tri", "Mesh" {
Properties60: {
Property: "Lcl Translation", "Lcl Translation", "A+",0,5,0
}
Vertices: 0,0,0,1,0,0,0,1,0,1,1,0
PolygonVertexIndex: 0,1,-3,1,3,-3
LayerElementNormal: 0 {
MappingInformationType: "ByVertice"
ReferenceInformationType: "IndexToDirect"
Normals: 0,0,1
NormalIndex: 0,0,0,0
}
LayerElementMaterial: 0 {
MappingInformationType: "ByPolygon"
ReferenceInformationType: "IndexToDirect"
Materials: 0,1
}
}
Material: "Material::Blue", "" {
ShadingModel: "lambert"
Properties60: {
Property: "Diffuse", "ColorRGB", "",0,0,1
}
}
Material: "Material::Green", "" {
Properties60: {
Property: "DiffuseColor", "ColorRGB", "",0,1,0
}
}
GlobalSettings: {
Version: 1000
Properties60: {
Property: "UpAxis", "int", "",2
Property: "FrontAxis", "int", "",1
Property: "FrontAxisSign", "int", "",-1
Property: "UnitScaleFactor", "double", "",100
}
}
}
Connections: {
Connect: "OO", "Model::Root", "Model::Scene"
Connect: "OO", "Model::Tri", "Model::Root"
Connect: "OO", "Material::Blue", "Model::Tri"
Connect: "OO", "Material::Green", "Model::Tri"
}
"#;
#[test]
fn fbx6_model_holds_its_mesh() {
let doc = document(FBX6.as_bytes());
let text = parse(FBX6.as_bytes()).unwrap();
let binary = write_binary(&doc.records, 6100, false);
assert_eq!(document(&binary).version, 6100);
assert_eq!(parse(&binary).unwrap(), text);
for (m, d) in [(&text, &doc), (&parse(&binary).unwrap(), &document(&binary))] {
m.validate().unwrap();
assert_eq!(m.nodes.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Root", "Tri"]);
assert_eq!((m.roots.as_slice(), m.nodes[0].children.as_slice(), m.nodes[1].mesh), (&[0][..], &[1][..], Some(0)));
let mesh = &m.meshes[0];
assert_eq!((mesh.positions.len(), mesh.normals.as_slice()), (4, &[[0.0, 0.0, 1.0]][..]));
// A part per material slot, in the order the materials connect to the Model.
let colors: Vec<[f32; 4]> = mesh.parts.iter().map(|p| m.materials[p.material.unwrap()].base_color).collect();
assert_eq!(colors, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]]);
assert_eq!(mesh.parts[1].corners, [1, 3, 2].map(|i| Corner { position: i, normal: 0, uv: NONE }));
// Translated by 5 inside a parent scaled by 2.
let world = m.instances(&Limits::default()).unwrap()[0].1;
assert_eq!(transform_point(&world, [1.0, 1.0, 0.0]), [2.0, 12.0, 0.0]);
// Z-up, from the settings among the objects.
assert_eq!(transform_point(&d.axes_to_y_up(), [1.0, 2.0, 3.0]), [1.0, 3.0, -2.0]);
assert_eq!(d.unit_scale(), Some(100.0));
}
}