Skip to main content

falcon_library/model3d/
fbx.rs

1//! FBX, binary and ASCII, read with ufbx (MIT, <https://github.com/ufbx/ufbx>).
2//!
3//! Every node with a mesh becomes one [`Mesh`] per material it uses, with
4//! the node's geometry-to-world transform baked into positions and normals.
5//! Polygons are split with ufbx's triangulation; normals the file lacks are
6//! generated by ufbx. Each material gives its base (diffuse) colour and
7//! opacity, and its diffuse (or base colour) texture when there is one:
8//! the picture comes from the file's embedded media, else from a file next
9//! to the model (see [`texture::Loader`]), and is laid on with the file's
10//! UVs (v flipped to picture space) and UV transform; wrap modes are ignored,
11//! textures always repeat (see [`texture`]). A
12//! picture that is missing or can't be decoded leaves the colour.
13//!
14//! The surface follows what Eagle's viewer shows for FBX: the `Bump` and
15//! `NormalMap` pictures (also Maya's `TEX_normal_map`) are read the same
16//! way and share the mesh's UVs; the one UV transform baked in is the base
17//! map's, else the normal map's, else the bump map's. A material is drawn
18//! see-through when its opacity is below 1 or a texture is connected to
19//! `TransparentColor` or `TransparencyFactor` (that picture is not drawn).
20//! Emissive colour and maps are deliberately not read: the viewer drops
21//! them for FBX, so the emissive stays black.
22//!
23//! The scene is converted to the viewer's convention: right-handed, Y up,
24//! and centimetres, the FBX default unit (the file's `UnitScaleFactor` is
25//! applied, so a file saved in metres shows sizes in centimetres too).
26//!
27//! Not read: other maps (emissive, specular, ...), vertex colours, skinning
28//! and blend shapes (meshes stay in their bind pose), animation, cameras,
29//! lights, NURBS and external files other than texture pictures.
30
31use std::collections::HashMap;
32
33use super::math::{self, V3};
34use super::texture::{self, Texture};
35use super::{Material, Mesh, Model, ParseError, Result};
36
37/// Most memory ufbx may use while parsing, and for the scene it returns;
38/// a broken or hostile file fails with a memory-limit error instead.
39const TEMP_LIMIT: usize = 1 << 30;
40const RESULT_LIMIT: usize = 2 << 30;
41/// Deepest node nesting accepted.
42const NODE_DEPTH_LIMIT: u32 = 256;
43/// Most output vertices, against files that instance a big mesh many times.
44const MAX_VERTICES: usize = 50_000_000;
45
46/// Metres per output unit: centimetres.
47const TARGET_UNIT_METERS: f64 = 0.01;
48
49fn limits(memory_limit: usize) -> ufbx::AllocatorOpts {
50    ufbx::AllocatorOpts { memory_limit, ..Default::default() }
51}
52
53pub fn parse(bytes: &[u8], side: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
54    let opts = ufbx::LoadOpts {
55        temp_allocator: limits(TEMP_LIMIT),
56        result_allocator: limits(RESULT_LIMIT),
57        ignore_animation: true,
58        // Embedded media are read for textures; files on disk are not
59        // opened by ufbx but fetched through `side`.
60        ignore_embedded: false,
61        load_external_files: false,
62        generate_missing_normals: true,
63        // Always FBX: an OBJ renamed .fbx is refused, not read by ufbx's
64        // OBJ parser.
65        file_format: ufbx::FileFormat::Fbx,
66        node_depth_limit: NODE_DEPTH_LIMIT,
67        space_conversion: ufbx::SpaceConversion::ModifyGeometry,
68        target_axes: ufbx::CoordinateAxes {
69            right: ufbx::CoordinateAxis::PositiveX,
70            up: ufbx::CoordinateAxis::PositiveY,
71            front: ufbx::CoordinateAxis::PositiveZ,
72        },
73        target_unit_meters: TARGET_UNIT_METERS,
74        ..Default::default()
75    };
76    let scene = ufbx::load_memory(bytes, opts).map_err(|e| ParseError(format!("not a readable FBX file: {}", e.description.trim())))?;
77    let mut model = Model::default();
78    let mut vertices = 0usize;
79    let mut loader = texture::Loader::new(side);
80    for node in &scene.nodes {
81        let Some(mesh) = node.mesh.as_ref() else { continue };
82        for out in node_meshes(node, mesh, &mut loader) {
83            vertices += out.positions.len();
84            if vertices > MAX_VERTICES {
85                return Err(ParseError::new("the FBX has too many vertices"));
86            }
87            model.meshes.push(out);
88        }
89    }
90    model.finish()
91}
92
93fn v3(v: ufbx::Vec3) -> V3 {
94    [v.x as f32, v.y as f32, v.z as f32]
95}
96
97/// The meshes one node draws: one per material part with triangles.
98fn node_meshes(node: &ufbx::Node, mesh: &ufbx::Mesh, loader: &mut texture::Loader) -> Vec<Mesh> {
99    let to_world = &node.geometry_to_world;
100    let normal_matrix = ufbx::matrix_for_normals(to_world);
101    // A mirroring transform turns the winding around; swap two corners to
102    // keep the front counter-clockwise.
103    let mirrored = ufbx::matrix_determinant(to_world) < 0.0;
104    // A node may override the mesh's materials per instance.
105    let materials = if node.materials.is_empty() { &mesh.materials } else { &node.materials };
106    let positions = &mesh.vertex_position;
107    let normals = &mesh.vertex_normal;
108    let uvs = &mesh.vertex_uv;
109    let mut tri = vec![0u32; mesh.max_face_triangles.max(1) * 3];
110    let mut out = Vec::new();
111    for part in mesh.material_parts.iter() {
112        if part.num_triangles == 0 {
113            continue;
114        }
115        let mut m = Mesh { name: node.element.name.to_string(), ..Default::default() };
116        let mat = materials.get(part.index as usize);
117        let tex = mat.and_then(|mat| map_texture(mat, loader));
118        // The normal and bump maps need the mesh's UVs, so are not decoded
119        // without them.
120        let (normal, bump) = match mat {
121            Some(mat) if uvs.exists => (
122                loader.for_map(|l| normal_texture(mat).and_then(|t| file_texture(t, l))),
123                loader.for_map(|l| mat.fbx.bump.texture.as_ref().and_then(|t| file_texture(t, l))),
124            ),
125            _ => (None, None),
126        };
127        // One transform for all the maps: the first map's that exists.
128        let transform = tex.as_ref().or(normal.as_ref()).or(bump.as_ref()).and_then(|t| t.1);
129        let mut ns: Vec<V3> = Vec::new();
130        let mut uv_out: Vec<[f32; 2]> = Vec::new();
131        // (position index, normal index, uv index) → output vertex.
132        let mut seen: HashMap<(u32, u32, u32), u32> = HashMap::new();
133        for &fi in part.face_indices.iter() {
134            let Some(&face) = mesh.faces.get(fi as usize) else { continue };
135            if face.num_indices < 3 {
136                continue;
137            }
138            let n = ufbx::triangulate_face(&mut tri, mesh, face) as usize;
139            for t in tri[..n * 3].as_chunks::<3>().0 {
140                let t = if mirrored { [t[0], t[2], t[1]] } else { *t };
141                for ix in t {
142                    // ufbx keeps every index in range; a miss reads as 0.
143                    let ix = ix as usize;
144                    let pi = positions.indices.get(ix).copied().unwrap_or(0);
145                    let ni = if normals.exists { normals.indices.get(ix).copied().unwrap_or(0) } else { u32::MAX };
146                    let ui = if uvs.exists { uvs.indices.get(ix).copied().unwrap_or(0) } else { u32::MAX };
147                    let next = m.positions.len() as u32;
148                    let v = *seen.entry((pi, ni, ui)).or_insert_with(|| {
149                        let p = positions.values.get(pi as usize).copied().unwrap_or_default();
150                        m.positions.push(v3(ufbx::transform_position(to_world, p)));
151                        let nv = normals.values.get(ni as usize).copied().unwrap_or_default();
152                        ns.push(math::normalize(v3(ufbx::transform_direction(&normal_matrix, nv))));
153                        if uvs.exists {
154                            let uv = uvs.values.get(ui as usize).copied().unwrap_or_default();
155                            uv_out.push(picture_uv(uv, transform.as_ref()));
156                        }
157                        next
158                    });
159                    m.indices.push(v);
160                }
161            }
162        }
163        if normals.exists {
164            m.normals = Some(ns);
165        }
166        if uvs.exists {
167            m.uvs = Some(uv_out);
168        }
169        // A picture without UVs can't be laid on.
170        m.texture = tex.filter(|_| uvs.exists).map(|t| t.0);
171        m.material = mat.map(|m| material(m));
172        m.surface.normal_map = normal.map(|t| t.0);
173        m.surface.bump_map = bump.map(|t| t.0);
174        m.surface.blend = mat.is_some_and(|mat| see_through(mat, m.material.as_ref().map_or(1.0, |m| m.opacity)));
175        out.push(m);
176    }
177    out
178}
179
180/// A texture coordinate in picture space: the texture's UV transform (when
181/// it has one) applied in the file's v-up space, then v flipped.
182fn picture_uv(uv: ufbx::Vec2, transform: Option<&ufbx::Matrix>) -> [f32; 2] {
183    let (mut u, mut v) = (uv.x, uv.y);
184    if let Some(t) = transform {
185        (u, v) = (t.m00 * uv.x + t.m01 * uv.y + t.m03, t.m10 * uv.x + t.m11 * uv.y + t.m13);
186    }
187    [u as f32, 1.0 - v as f32]
188}
189
190/// The material's diffuse texture, decoded, with its UV transform if any.
191fn map_texture(mat: &ufbx::Material, loader: &mut texture::Loader) -> Option<(Texture, Option<ufbx::Matrix>)> {
192    let tex = mat.pbr.base_color.texture.as_ref().or(mat.fbx.diffuse_color.texture.as_ref())?;
193    file_texture(tex, loader)
194}
195
196/// The texture connected as the normal map (FBX `NormalMap`, Maya's
197/// `TEX_normal_map`).
198fn normal_texture(mat: &ufbx::Material) -> Option<&ufbx::Texture> {
199    mat.fbx.normal_map.texture.as_ref().or(mat.pbr.normal_map.texture.as_ref()).map(|t| &**t)
200}
201
202/// The picture of a texture, decoded, with its UV transform if any.
203fn file_texture(tex: &ufbx::Texture, loader: &mut texture::Loader) -> Option<(Texture, Option<ufbx::Matrix>)> {
204    // A layered or procedural texture: its first picture.
205    let file = if tex.type_ == ufbx::TextureType::File { Some(tex) } else { tex.file_textures.first().map(|t| &**t) }?;
206    let name = |p: &str| p.rsplit(['/', '\\']).next().unwrap_or(p).to_string();
207    // An empty blob has a null pointer: test its size, not the slice.
208    let image = if file.content.size > 0 {
209        loader.embedded(&name(&file.filename), &file.content)
210    } else {
211        [&file.relative_filename, &file.filename]
212            .into_iter()
213            .filter(|p| !p.is_empty())
214            .find_map(|p| loader.file(&p.replace('\\', "/")))
215    }?;
216    Some((Texture::new(image), file.has_uv_transform.then_some(file.uv_to_texture)))
217}
218
219/// Whether the material is drawn see-through: opacity below 1, or a
220/// texture on its transparency colour or factor.
221fn see_through(mat: &ufbx::Material, opacity: f32) -> bool {
222    opacity < 1.0 - 1e-6 || mat.fbx.transparency_color.texture.is_some() || mat.fbx.transparency_factor.texture.is_some()
223}
224
225fn material(mat: &ufbx::Material) -> Material {
226    let pbr = &mat.pbr;
227    let c = pbr.base_color.value_vec4;
228    let factor = if pbr.base_factor.has_value { pbr.base_factor.value_vec4.x } else { 1.0 };
229    let color = [c.x, c.y, c.z].map(|v| ((v * factor) as f32).clamp(0.0, 1.0));
230    let color = if pbr.base_color.has_value { color } else { super::DEFAULT_COLOR };
231    // Only the `Opacity` property, as in Eagle's viewer. `TransparencyFactor`
232    // is no guide: exporters commonly write 1 on fully opaque materials.
233    let opacity = ufbx::find_prop(&mat.element.props, "Opacity").map_or(1.0f32, |p| p.value_vec4.x as f32);
234    // NaN counts as opaque.
235    let opacity = if opacity.is_nan() { 1.0 } else { opacity.clamp(0.0, 1.0) };
236    Material { name: mat.element.name.to_string(), color, opacity }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// A property of a hand-written FBX node.
244    enum P {
245        /// A 64-bit id (`L` in binary).
246        Id(i64),
247        /// A 32-bit integer (`I`).
248        Int(i32),
249        D(f64),
250        S(&'static str),
251        /// An object name: `Class::Name` in ASCII, `Name\0\x01Class` in
252        /// binary.
253        Name(&'static str, &'static str),
254        Ints(Vec<i32>),
255        Doubles(Vec<f64>),
256        /// Raw bytes (`R`): binary files only.
257        Bytes(Vec<u8>),
258    }
259
260    struct N {
261        name: &'static str,
262        props: Vec<P>,
263        children: Vec<N>,
264    }
265
266    fn n(name: &'static str, props: Vec<P>, children: Vec<N>) -> N {
267        N { name, props, children }
268    }
269
270    /// `P: "name", "type", "", "", values...` of a Properties70 block.
271    fn prop(name: &'static str, ty: &'static str, values: Vec<P>) -> N {
272        let mut props = vec![P::S(name), P::S(ty), P::S(""), P::S("")];
273        props.extend(values);
274        n("P", props, vec![])
275    }
276
277    fn ascii(nodes: &[N]) -> Vec<u8> {
278        fn write(out: &mut String, node: &N, depth: usize) {
279            let pad = "\t".repeat(depth);
280            out.push_str(&format!("{pad}{}: ", node.name));
281            let mut arrays = Vec::new();
282            let parts: Vec<String> = node
283                .props
284                .iter()
285                .map(|p| match p {
286                    P::Id(v) => v.to_string(),
287                    P::Int(v) => v.to_string(),
288                    P::D(v) => format!("{v:?}"),
289                    P::S(s) => format!("\"{s}\""),
290                    P::Name(class, name) => format!("\"{class}::{name}\""),
291                    P::Ints(v) => {
292                        arrays.push(v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","));
293                        format!("*{}", v.len())
294                    }
295                    P::Bytes(_) => unreachable!("raw bytes in an ASCII file"),
296                    P::Doubles(v) => {
297                        arrays.push(v.iter().map(|x| format!("{x:?}")).collect::<Vec<_>>().join(","));
298                        format!("*{}", v.len())
299                    }
300                })
301                .collect();
302            out.push_str(&parts.join(", "));
303            if let Some(a) = arrays.first() {
304                out.push_str(&format!(" {{\n{pad}\ta: {a}\n{pad}}}\n"));
305            } else if node.children.is_empty() {
306                out.push('\n');
307            } else {
308                out.push_str(" {\n");
309                for c in &node.children {
310                    write(out, c, depth + 1);
311                }
312                out.push_str(&format!("{pad}}}\n"));
313            }
314        }
315        let mut out = String::from("; FBX 7.4.0 project file\n");
316        for node in nodes {
317            write(&mut out, node, 0);
318        }
319        out.into_bytes()
320    }
321
322    fn binary(nodes: &[N]) -> Vec<u8> {
323        fn write(out: &mut Vec<u8>, node: &N) {
324            let start = out.len();
325            out.extend([0u8; 12]);
326            out.push(node.name.len() as u8);
327            out.extend(node.name.as_bytes());
328            let props_start = out.len();
329            for p in &node.props {
330                match p {
331                    P::Id(v) => {
332                        out.push(b'L');
333                        out.extend(v.to_le_bytes());
334                    }
335                    P::Int(v) => {
336                        out.push(b'I');
337                        out.extend(v.to_le_bytes());
338                    }
339                    P::D(v) => {
340                        out.push(b'D');
341                        out.extend(v.to_le_bytes());
342                    }
343                    P::S(s) => {
344                        out.push(b'S');
345                        out.extend((s.len() as u32).to_le_bytes());
346                        out.extend(s.as_bytes());
347                    }
348                    P::Name(class, name) => {
349                        let s = format!("{name}\0\x01{class}");
350                        out.push(b'S');
351                        out.extend((s.len() as u32).to_le_bytes());
352                        out.extend(s.as_bytes());
353                    }
354                    P::Bytes(v) => {
355                        out.push(b'R');
356                        out.extend((v.len() as u32).to_le_bytes());
357                        out.extend(v);
358                    }
359                    P::Ints(v) => {
360                        out.push(b'i');
361                        out.extend((v.len() as u32).to_le_bytes());
362                        out.extend(0u32.to_le_bytes());
363                        out.extend((v.len() as u32 * 4).to_le_bytes());
364                        v.iter().for_each(|x| out.extend(x.to_le_bytes()));
365                    }
366                    P::Doubles(v) => {
367                        out.push(b'd');
368                        out.extend((v.len() as u32).to_le_bytes());
369                        out.extend(0u32.to_le_bytes());
370                        out.extend((v.len() as u32 * 8).to_le_bytes());
371                        v.iter().for_each(|x| out.extend(x.to_le_bytes()));
372                    }
373                }
374            }
375            let props_len = (out.len() - props_start) as u32;
376            if !node.children.is_empty() {
377                for c in &node.children {
378                    write(out, c);
379                }
380                out.extend([0u8; 13]);
381            }
382            let end = out.len() as u32;
383            out[start..start + 4].copy_from_slice(&end.to_le_bytes());
384            out[start + 4..start + 8].copy_from_slice(&(node.props.len() as u32).to_le_bytes());
385            out[start + 8..start + 12].copy_from_slice(&props_len.to_le_bytes());
386        }
387        let mut out = b"Kaydara FBX Binary  \x00\x1a\x00".to_vec();
388        out.extend(7400u32.to_le_bytes());
389        for node in nodes {
390            write(&mut out, node);
391        }
392        out.extend([0u8; 13]);
393        out
394    }
395
396    fn header() -> N {
397        n("FBXHeaderExtension", vec![], vec![n("FBXHeaderVersion", vec![P::Int(1003)], vec![]), n("FBXVersion", vec![P::Int(7400)], vec![])])
398    }
399
400    /// Global settings with `up` (0 X, 1 Y, 2 Z) as the up axis and the
401    /// given unit scale (centimetres per file unit).
402    fn settings(up: i32, unit_scale: f64) -> N {
403        let front = if up == 2 { 1 } else { 2 };
404        let front_sign = if up == 2 { -1 } else { 1 };
405        n(
406            "GlobalSettings",
407            vec![],
408            vec![
409                n("Version", vec![P::Int(1000)], vec![]),
410                n(
411                    "Properties70",
412                    vec![],
413                    vec![
414                        prop("UpAxis", "int", vec![P::Int(up)]),
415                        prop("UpAxisSign", "int", vec![P::Int(1)]),
416                        prop("FrontAxis", "int", vec![P::Int(front)]),
417                        prop("FrontAxisSign", "int", vec![P::Int(front_sign)]),
418                        prop("CoordAxis", "int", vec![P::Int(0)]),
419                        prop("CoordAxisSign", "int", vec![P::Int(1)]),
420                        prop("UnitScaleFactor", "double", vec![P::D(unit_scale)]),
421                        prop("OriginalUnitScaleFactor", "double", vec![P::D(unit_scale)]),
422                    ],
423                ),
424            ],
425        )
426    }
427
428    /// A file with one box from (0,0,0) to (1,1,depth) as six quads,
429    /// moved by `tx` along X, in a red material, without normals.
430    fn box_file(up: i32, unit_scale: f64, tx: f64, depth: f64) -> Vec<N> {
431        let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
432        let verts: Vec<f64> = corners.iter().flat_map(|c: &[f64; 3]| [c[0], c[1], c[2] * depth]).collect();
433        let quads = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [2, 3, 7, 6], [1, 2, 6, 5], [0, 4, 7, 3]];
434        // The last corner of each polygon is stored as -(index + 1).
435        let idx: Vec<i32> = quads.iter().flat_map(|q| [q[0], q[1], q[2], -q[3] - 1]).collect();
436        vec![
437            header(),
438            settings(up, unit_scale),
439            n(
440                "Objects",
441                vec![],
442                vec![
443                    n(
444                        "Geometry",
445                        vec![P::Id(1000), P::Name("Geometry", "Box"), P::S("Mesh")],
446                        vec![n("Vertices", vec![P::Doubles(verts)], vec![]), n("PolygonVertexIndex", vec![P::Ints(idx)], vec![])],
447                    ),
448                    n(
449                        "Model",
450                        vec![P::Id(2000), P::Name("Model", "Box"), P::S("Mesh")],
451                        vec![
452                            n("Version", vec![P::Int(232)], vec![]),
453                            n("Properties70", vec![], vec![prop("Lcl Translation", "Lcl Translation", vec![P::D(tx), P::D(0.0), P::D(0.0)])]),
454                        ],
455                    ),
456                    n(
457                        "Material",
458                        vec![P::Id(3000), P::Name("Material", "Red"), P::S("")],
459                        vec![
460                            n("Version", vec![P::Int(102)], vec![]),
461                            n("ShadingModel", vec![P::S("phong")], vec![]),
462                            n("Properties70", vec![], vec![prop("DiffuseColor", "Color", vec![P::D(1.0), P::D(0.0), P::D(0.0)])]),
463                        ],
464                    ),
465                ],
466            ),
467            n(
468                "Connections",
469                vec![],
470                vec![
471                    n("C", vec![P::S("OO"), P::Id(1000), P::Id(2000)], vec![]),
472                    n("C", vec![P::S("OO"), P::Id(2000), P::Id(0)], vec![]),
473                    n("C", vec![P::S("OO"), P::Id(3000), P::Id(2000)], vec![]),
474                ],
475            ),
476        ]
477    }
478
479    fn size(m: &Model) -> V3 {
480        let (lo, hi) = m.bounds().unwrap();
481        math::sub(hi, lo)
482    }
483
484    fn close(a: V3, b: V3) -> bool {
485        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
486    }
487
488    #[test]
489    fn ascii_and_binary_boxes_read_the_same() {
490        for (kind, bytes) in [("ascii", ascii(&box_file(1, 1.0, 10.0, 1.0))), ("binary", binary(&box_file(1, 1.0, 10.0, 1.0)))] {
491            let m = parse(&bytes, &|_| None).unwrap_or_else(|e| panic!("{kind}: {e}"));
492            assert_eq!(m.meshes.len(), 1, "{kind}");
493            assert_eq!(m.triangle_count(), 12, "{kind}: six quads, two triangles each");
494            let mesh = &m.meshes[0];
495            assert_eq!(mesh.name, "Box");
496            let mat = mesh.material.as_ref().unwrap();
497            assert_eq!((mat.name.as_str(), mat.color), ("Red", [1.0, 0.0, 0.0]), "{kind}");
498            // The node's translation is applied.
499            let (lo, hi) = m.bounds().unwrap();
500            assert!(close(lo, [10.0, 0.0, 0.0]) && close(hi, [11.0, 1.0, 1.0]), "{kind}: {lo:?} {hi:?}");
501            // Normals were generated, one per vertex, of unit length.
502            let ns = mesh.normals.as_ref().expect("generated normals");
503            assert_eq!(ns.len(), mesh.positions.len());
504            assert!(ns.iter().all(|v| (math::length(*v) - 1.0).abs() < 1e-4));
505            let s = super::super::geom::stats(&m);
506            assert!((s.volume.unwrap() - 1.0).abs() < 1e-4, "{kind}: a closed box");
507        }
508    }
509
510    /// Two triangles of a square with UVs (the second triangle's corner at
511    /// position 0 has its own UV, a seam), in a red material that has a
512    /// texture `tex.png`,
513    /// with an embedded picture when `content` is given.
514    fn textured_file(content: Option<Vec<u8>>) -> Vec<N> {
515        textured_on("DiffuseColor", content, true, vec![])
516    }
517
518    /// As [`textured_file`] with the texture connected to the material
519    /// property `on`, the UVs left out unless `uvs`, and `extra` material
520    /// properties added.
521    fn textured_on(on: &'static str, content: Option<Vec<u8>>, uvs: bool, extra: Vec<N>) -> Vec<N> {
522        let verts = vec![0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.];
523        let uv = vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.25, 0.0, 0.0, 1.0];
524        let mut texture = vec![
525            n("Type", vec![P::S("TextureVideoClip")], vec![]),
526            n("FileName", vec![P::S("/elsewhere/tex.png")], vec![]),
527            n("RelativeFilename", vec![P::S("tex.png")], vec![]),
528        ];
529        let mut objects = vec![
530            n(
531                "Geometry",
532                vec![P::Id(1000), P::Name("Geometry", "Quad"), P::S("Mesh")],
533                vec![
534                    n("Vertices", vec![P::Doubles(verts)], vec![]),
535                    n("PolygonVertexIndex", vec![P::Ints(vec![0, 1, -3, 0, 2, -4])], vec![]),
536                    n(
537                        if uvs { "LayerElementUV" } else { "Unused" },
538                        vec![P::Int(0)],
539                        vec![
540                            n("Version", vec![P::Int(101)], vec![]),
541                            n("Name", vec![P::S("UVMap")], vec![]),
542                            n("MappingInformationType", vec![P::S("ByPolygonVertex")], vec![]),
543                            n("ReferenceInformationType", vec![P::S("IndexToDirect")], vec![]),
544                            n("UV", vec![P::Doubles(uv)], vec![]),
545                            n("UVIndex", vec![P::Ints(vec![0, 1, 2, 3, 2, 4])], vec![]),
546                        ],
547                    ),
548                ],
549            ),
550            n("Model", vec![P::Id(2000), P::Name("Model", "Quad"), P::S("Mesh")], vec![n("Version", vec![P::Int(232)], vec![])]),
551            n(
552                "Material",
553                vec![P::Id(3000), P::Name("Material", "Red"), P::S("")],
554                vec![
555                    n("ShadingModel", vec![P::S("phong")], vec![]),
556                    n("Properties70", vec![], std::iter::once(prop("DiffuseColor", "Color", vec![P::D(1.0), P::D(0.0), P::D(0.0)])).chain(extra).collect()),
557                ],
558            ),
559        ];
560        let mut connections = vec![
561            n("C", vec![P::S("OO"), P::Id(1000), P::Id(2000)], vec![]),
562            n("C", vec![P::S("OO"), P::Id(2000), P::Id(0)], vec![]),
563            n("C", vec![P::S("OO"), P::Id(3000), P::Id(2000)], vec![]),
564            n("C", vec![P::S("OP"), P::Id(4000), P::Id(3000), P::S(on)], vec![]),
565        ];
566        if let Some(bytes) = content {
567            // The embedded picture is not the sidecar file, so a test can
568            // tell which one was used.
569            objects.push(n(
570                "Video",
571                vec![P::Id(5000), P::Name("Video", "Clip"), P::S("Clip")],
572                vec![
573                    n("Type", vec![P::S("Clip")], vec![]),
574                    n("RelativeFilename", vec![P::S("tex.png")], vec![]),
575                    n("Content", vec![P::Bytes(bytes)], vec![]),
576                ],
577            ));
578            connections.push(n("C", vec![P::S("OO"), P::Id(5000), P::Id(4000)], vec![]));
579        }
580        texture.push(n("Version", vec![P::Int(202)], vec![]));
581        objects.push(n("Texture", vec![P::Id(4000), P::Name("Texture", "Tex"), P::S("")], texture));
582        vec![header(), settings(1, 1.0), n("Objects", vec![], objects), n("Connections", vec![], connections)]
583    }
584
585    #[test]
586    fn uvs_and_a_sidecar_texture_are_read() {
587        let png = super::texture::tests::png_2x2();
588        let side = |name: &str| (name == "tex.png").then(|| png.clone());
589        let m = parse(&ascii(&textured_file(None)), &side).unwrap();
590        let mesh = &m.meshes[0];
591        // Six corners: the generated flat normals already split the shared
592        // positions, so the seam is checked through the UVs below.
593        assert_eq!(mesh.positions.len(), 6);
594        let uvs = mesh.uvs.as_ref().expect("uvs");
595        assert_eq!(uvs.len(), 6);
596        // Position 0 carries both its UVs, one per triangle.
597        let origin: Vec<[f32; 2]> = (0..6).filter(|&i| close(mesh.positions[i], [0.0; 3])).map(|i| uvs[i]).collect();
598        assert!(origin.contains(&[0.0, 1.0]) && origin.contains(&[0.25, 1.0]), "{origin:?}");
599        // v is flipped to picture space.
600        let at = |p: V3| mesh.positions.iter().position(|q| close(*q, p)).map(|i| uvs[i]);
601        assert_eq!(at([1.0, 1.0, 0.0]), Some([1.0, 0.0]));
602        assert_eq!(at([1.0, 0.0, 0.0]), Some([1.0, 1.0]));
603        assert!(uvs.contains(&[0.0, 0.0]));
604        let tex = mesh.texture.as_ref().expect("texture");
605        assert_eq!((tex.image.width, tex.image.height), (2, 2));
606        assert_eq!(tex.tint, [1.0; 3]);
607        let mat = mesh.material.as_ref().unwrap();
608        assert_eq!(mat.color, [1.0, 0.0, 0.0]);
609    }
610
611    #[test]
612    fn a_missing_texture_keeps_the_colour() {
613        let m = parse(&ascii(&textured_file(None)), &|_| None).unwrap();
614        let mesh = &m.meshes[0];
615        assert!(mesh.texture.is_none());
616        assert!(mesh.uvs.is_some(), "UVs are still read");
617        assert_eq!(mesh.material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
618        // Bytes that are not a picture are the same as a missing file.
619        let m = parse(&ascii(&textured_file(None)), &|_| Some(b"nope".to_vec())).unwrap();
620        assert!(m.meshes[0].texture.is_none());
621        // A mesh without UVs has no texture to lay on.
622        let m = parse(&ascii(&box_file(1, 1.0, 0.0, 1.0)), &|_| Some(super::texture::tests::png_2x2())).unwrap();
623        assert!(m.meshes[0].uvs.is_none() && m.meshes[0].texture.is_none());
624    }
625
626    #[test]
627    fn bump_and_normal_sidecar_maps_are_read() {
628        let png = super::texture::tests::png_2x2();
629        let side = |name: &str| (name == "tex.png").then(|| png.clone());
630        for (on, want_normal) in [("Bump", false), ("NormalMap", true)] {
631            let m = parse(&ascii(&textured_on(on, None, true, vec![])), &side).unwrap();
632            let mesh = &m.meshes[0];
633            let s = &mesh.surface;
634            assert!(mesh.texture.is_none(), "{on}: not the base map");
635            let got = if want_normal { &s.normal_map } else { &s.bump_map };
636            assert_eq!(got.as_ref().map(|t| t.image.width), Some(2), "{on}");
637            assert!(if want_normal { s.bump_map.is_none() } else { s.normal_map.is_none() }, "{on}");
638            assert!(s.emissive_map.is_none() && s.emissive == [0.0; 3] && !s.blend, "{on}");
639            assert!(mesh.uvs.is_some());
640        }
641    }
642
643    #[test]
644    fn an_embedded_normal_map_is_read_from_binary() {
645        let embedded = super::texture::tests::png(3, 1, [9, 8, 7, 255]);
646        let m = parse(&binary(&textured_on("NormalMap", Some(embedded), true, vec![])), &|_| None).unwrap();
647        let tex = m.meshes[0].surface.normal_map.as_ref().expect("normal map");
648        assert_eq!((tex.image.width, tex.image.height), (3, 1));
649    }
650
651    #[test]
652    fn opacity_and_transparency_textures_set_blend() {
653        let opacity = |v: f64| vec![prop("Opacity", "double", vec![P::D(v)])];
654        let blend = |file: Vec<N>| parse(&ascii(&file), &|_| None).unwrap().meshes[0].surface.blend;
655        assert!(blend(textured_on("DiffuseColor", None, true, opacity(0.5))));
656        assert!(!blend(textured_on("DiffuseColor", None, true, opacity(1.0))));
657        assert!(!blend(textured_on("DiffuseColor", None, true, vec![])));
658        // A TransparencyFactor value alone is not opacity: exporters write 1
659        // on opaque materials.
660        let factor = vec![prop("TransparencyFactor", "double", vec![P::D(1.0)])];
661        assert!(!blend(textured_on("DiffuseColor", None, true, factor)));
662        // A transparency texture alone, with no picture to read.
663        assert!(blend(textured_on("TransparencyFactor", None, true, vec![])));
664        assert!(blend(textured_on("TransparentColor", None, true, vec![])));
665        let png = super::texture::tests::png_2x2();
666        let m = parse(&ascii(&textured_on("TransparencyFactor", None, true, vec![])), &|_| Some(png.clone())).unwrap();
667        assert!(m.meshes[0].surface.blend && m.meshes[0].texture.is_none(), "the transparency picture is not drawn");
668        assert_eq!(m.meshes[0].material.as_ref().unwrap().opacity, 1.0);
669    }
670
671    #[test]
672    fn a_missing_or_bad_map_stays_none() {
673        for side in [&(|_: &str| None) as &dyn Fn(&str) -> Option<Vec<u8>>, &|_: &str| Some(b"nope".to_vec())] {
674            let m = parse(&ascii(&textured_on("Bump", None, true, vec![])), side).unwrap();
675            assert_eq!(m.meshes.len(), 1);
676            assert!(m.meshes[0].surface.bump_map.is_none() && m.meshes[0].uvs.is_some());
677            assert_eq!(m.meshes[0].material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
678        }
679    }
680
681    #[test]
682    fn maps_without_uvs_are_dropped() {
683        let png = super::texture::tests::png_2x2();
684        for on in ["Bump", "NormalMap"] {
685            let m = parse(&ascii(&textured_on(on, None, false, vec![])), &|_| Some(png.clone())).unwrap();
686            let mesh = &m.meshes[0];
687            assert!(mesh.uvs.is_none() && mesh.surface.bump_map.is_none() && mesh.surface.normal_map.is_none(), "{on}");
688        }
689    }
690
691    #[test]
692    fn an_embedded_picture_wins_over_the_sidecar() {
693        let embedded = super::texture::tests::png(3, 1, [9, 8, 7, 255]);
694        let sidecar = super::texture::tests::png_2x2();
695        let side = |name: &str| (name == "tex.png").then(|| sidecar.clone());
696        let m = parse(&binary(&textured_file(Some(embedded))), &side).unwrap();
697        let tex = m.meshes[0].texture.as_ref().expect("texture");
698        assert_eq!((tex.image.width, tex.image.height), (3, 1));
699        // The binary file alone, with no sidecar, still textures.
700        let m = parse(&binary(&textured_file(None)), &side).unwrap();
701        assert_eq!(m.meshes[0].texture.as_ref().unwrap().image.width, 2);
702    }
703
704    #[test]
705    fn unit_scale_and_up_axis_are_converted() {
706        // Metres (100 cm per unit): a 1-unit box is 100 cm.
707        let m = parse(&binary(&box_file(1, 100.0, 0.0, 1.0)), &|_| None).unwrap();
708        assert!(close(size(&m), [100.0, 100.0, 100.0]), "{:?}", size(&m));
709        // Millimetres.
710        let m = parse(&ascii(&box_file(1, 0.1, 0.0, 1.0)), &|_| None).unwrap();
711        assert!(close(size(&m), [0.1, 0.1, 0.1]), "{:?}", size(&m));
712        // Z up: the box's depth (along Z in the file) becomes its height.
713        let m = parse(&ascii(&box_file(2, 1.0, 0.0, 3.0)), &|_| None).unwrap();
714        assert!(close(size(&m), [1.0, 3.0, 1.0]), "{:?}", size(&m));
715    }
716
717    #[test]
718    fn files_load_by_extension_and_get_a_thumbnail() {
719        let dir = tempfile::tempdir().unwrap();
720        let path = dir.path().join("Box.FBX");
721        std::fs::write(&path, binary(&box_file(1, 1.0, 0.0, 1.0))).unwrap();
722        assert_eq!(super::super::load(&path, "FBX").unwrap().triangle_count(), 12);
723        let img = super::super::raster::thumbnail(&path, "fbx").expect("a drawn thumbnail");
724        assert!(img.pixels().any(|p| p[3] == 255 && p[0] > p[1]), "drawn in the red material");
725    }
726
727    #[test]
728    fn empty_scene_is_an_error_not_a_panic() {
729        let e = parse(&ascii(&[header(), settings(1, 1.0)]), &|_| None).unwrap_err();
730        assert!(e.0.contains("no triangles"), "{e}");
731        assert!(parse(&binary(&[header(), settings(1, 1.0)]), &|_| None).is_err());
732    }
733
734    #[test]
735    fn corrupt_and_truncated_files_fail_cleanly() {
736        assert!(parse(b"", &|_| None).is_err());
737        assert!(parse(b"not an fbx at all", &|_| None).is_err());
738        // An OBJ is not read as FBX.
739        assert!(parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", &|_| None).is_err());
740        let whole = binary(&box_file(1, 1.0, 0.0, 1.0));
741        for cut in [30, whole.len() / 3, whole.len() / 2, whole.len() - 20] {
742            assert!(parse(&whole[..cut], &|_| None).is_err(), "cut at {cut}");
743        }
744        // An array claiming four billion doubles fails quickly.
745        let mut huge = whole.clone();
746        let at = huge.windows(4).position(|w| w == b"Vert").unwrap() + 8;
747        assert_eq!(huge[at], b'd');
748        huge[at + 1..at + 5].copy_from_slice(&u32::MAX.to_le_bytes());
749        let t = std::time::Instant::now();
750        assert!(parse(&huge, &|_| None).is_err());
751        assert!(t.elapsed() < std::time::Duration::from_secs(5));
752        // Random bytes after a valid header.
753        let mut junk = whole[..27].to_vec();
754        let mut x = 0x2545_f491_u32;
755        junk.extend((0..4096).map(|_| {
756            x ^= x << 13;
757            x ^= x >> 17;
758            x ^= x << 5;
759            x as u8
760        }));
761        assert!(parse(&junk, &|_| None).is_err());
762    }
763}