Skip to main content

ranim_items/mesh/
gltf.rs

1//! glTF 2.0 scene-graph import into trees of [`MeshItem`]s, opt-in via the
2//! `gltf` cargo feature. The `gltf` crate itself is re-exported here, so
3//! callers can parse documents without their own matching dependency.
4//!
5//! Two loaders: [`node_tree_from_path`]
6//! reads a `.glb` (embedded blob) or `.gltf` (external buffer files resolved
7//! relative to the file) from disk, and the I/O-free
8//! [`node_tree_from_gltf`] takes a
9//! parsed document plus a buffer resolver.
10//!
11//! # Mapping
12//!
13//! | glTF                       | ranim                                              |
14//! |----------------------------|----------------------------------------------------|
15//! | node TRS / matrix          | [`Node`] pose as a [`DAffine3`] (`T * R * S`) |
16//! | node name (non-empty)      | [`Node::id`](crate::hierarchy::Node::id) (payload nodes fall back to the mesh name) |
17//! | node children              | [`Node::children`](crate::hierarchy::Node::children), source order kept |
18//! | mesh (single primitive)    | the node's own payload [`MeshItem`]                |
19//! | mesh (multiple primitives) | primitive leaves before the children, identity transforms |
20//!
21//! glTF also addresses nodes by document index (animation channels,
22//! `skin.joints`) — [`GltfTree::node`](crate::mesh::gltf::GltfTree::node)
23//! resolves that, [`Node::by_id`](crate::hierarchy::Node::by_id) resolves
24//! names. `POSITION`/indices/`NORMAL`/`COLOR_0` map to the [`MeshItem`]
25//! fields with zero-normals and default colors as the absent-attribute
26//! fallbacks; `COLOR_0` is stored as-is (no sRGB conversion for normalized
27//! integer variants).
28//!
29//! # Scope
30//!
31//! Only the default scene imports (missing scene → empty tree). Cameras,
32//! lights, materials/textures (color comes from `COLOR_0` or whatever you
33//! set after loading), `TEXCOORD`/`TANGENT` streams, animations, skins,
34//! morph targets and all extensions are not interpreted — Draco-compressed
35//! primitives therefore import as empty meshes with a warning.
36//! Non-`TRIANGLES` modes import their indices unchanged after a warning.
37//! glTF's mandated Y-up is converted to ranim's Z-up by composing
38//! `Rx(π/2)` into the root pose — apply the inverse there for verbatim
39//! coordinates.
40//!
41//! # Examples
42//!
43//! ```rust,no_run
44//! # fn main() -> Result<(), ranim_items::mesh::gltf::GltfLoadError> {
45//! use ranim_items::mesh::gltf::node_tree_from_path;
46//!
47//! let tree = node_tree_from_path("model.glb")?;
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! For custom I/O, parse yourself and hand over a buffer resolver:
53//!
54//! ```rust,no_run
55//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
56//! use ranim_items::mesh::gltf::{gltf, node_tree_from_gltf};
57//!
58//! let bytes = std::fs::read("model.gltf")?;
59//! let parsed = gltf::Gltf::from_slice(&bytes)?;
60//! let blob = parsed.blob.clone();
61//! let tree = node_tree_from_gltf(&parsed.document, |buffer| match buffer.source() {
62//!     gltf::buffer::Source::Bin => blob.as_deref(),
63//!     gltf::buffer::Source::Uri(_) => None, // read the file here
64//! });
65//! # Ok(())
66//! # }
67//! ```
68
69use std::path::Path;
70
71/// The `gltf` crate this module is built on, re-exported so callers can
72/// parse documents (and match on [`GltfLoadError::Parse`]'s error type)
73/// without their own matching dependency.
74pub use gltf;
75
76use ranim_core::transformed::Transformed;
77
78use crate::hierarchy::Node;
79use crate::mesh::MeshItem;
80use ranim_core::components::rgba::Rgba;
81use ranim_core::glam::{DAffine3, DMat4, DQuat, DVec3, Vec4, dvec3};
82
83/// A glTF scene imported as a [`Node`] tree, plus the mapping from glTF node
84/// indices to index paths in the tree.
85///
86/// glTF addresses nodes structurally by index (animation channels and
87/// `skin.joints` reference the document's node array), while names are
88/// optional display labels that are neither unique nor guaranteed present.
89/// This type carries both views: [`GltfTree::node`] resolves a document
90/// index, and [`Node::by_id`](crate::hierarchy::Node::by_id) resolves a
91/// label.
92pub struct GltfTree {
93    /// The default scene (or the first scene) as a node tree; the scene's
94    /// root nodes are direct children of this synthetic root group.
95    pub tree: Node<MeshItem>,
96    /// glTF node index → index path into [`GltfTree::tree`] (see
97    /// [`Node::get`](crate::hierarchy::Node::get)). `None` for document
98    /// nodes that are not part of the imported scene.
99    pub node_paths: Vec<Option<Vec<usize>>>,
100}
101
102impl GltfTree {
103    /// The tree node for glTF node `index`, or `None` when the index is out
104    /// of range or the node is not part of the imported scene.
105    pub fn node(&self, index: usize) -> Option<&Transformed<Node<MeshItem>, DAffine3>> {
106        self.tree.get(self.node_paths.get(index)?.as_deref()?)
107    }
108
109    /// Mutable variant of [`GltfTree::node`].
110    pub fn node_mut(&mut self, index: usize) -> Option<&mut Transformed<Node<MeshItem>, DAffine3>> {
111        self.tree.get_mut(self.node_paths.get(index)?.as_deref()?)
112    }
113}
114
115impl std::ops::Deref for GltfTree {
116    type Target = Node<MeshItem>;
117
118    fn deref(&self) -> &Self::Target {
119        &self.tree
120    }
121}
122
123/// glTF mandates a right-handed Y-up; ranim is Z-up. The conversion is a
124/// fixed, spec-mandated convention translation, composed into the root
125/// pose so a loaded model stands upright and no vertex data moves.
126fn y_up_to_z_up() -> DAffine3 {
127    DAffine3::from_rotation_x(std::f64::consts::FRAC_PI_2)
128}
129
130/// Errors from loading a glTF/GLB file via [`node_tree_from_path`].
131#[derive(Debug)]
132pub enum GltfLoadError {
133    /// The file could not be read.
134    Io(std::io::Error),
135    /// The file is not valid glTF/GLB.
136    Parse(gltf::Error),
137}
138
139impl std::fmt::Display for GltfLoadError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            GltfLoadError::Io(error) => write!(f, "failed to read glTF file: {error}"),
143            GltfLoadError::Parse(error) => write!(f, "failed to parse glTF file: {error}"),
144        }
145    }
146}
147
148impl std::error::Error for GltfLoadError {
149    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150        match self {
151            GltfLoadError::Io(error) => Some(error),
152            GltfLoadError::Parse(error) => Some(error),
153        }
154    }
155}
156
157/// Loads a `.glb` or `.gltf` file from disk into a [`GltfTree`].
158///
159/// A GLB's embedded blob is used directly. For a `.gltf`, external buffer
160/// files (`"uri"` fields) are read relative to the file's directory, joined
161/// as raw paths (no percent-decoding yet); `data:` URIs are not decoded and
162/// their buffers are skipped with a warning — the affected primitives fall
163/// back to their documented defaults.
164pub fn node_tree_from_path(path: impl AsRef<Path>) -> Result<GltfTree, GltfLoadError> {
165    let path = path.as_ref();
166    let bytes = std::fs::read(path).map_err(GltfLoadError::Io)?;
167    let gltf = gltf::Gltf::from_slice(&bytes).map_err(GltfLoadError::Parse)?;
168    let blob = gltf.blob.clone();
169    let base = path.parent().unwrap_or_else(|| Path::new("."));
170
171    // External buffer files are read up-front so the returned slices
172    // outlive the resolver closure handed to [`node_tree_from_gltf`].
173    let external: Vec<Option<Vec<u8>>> = gltf
174        .document
175        .buffers()
176        .map(|buffer| match buffer.source() {
177            gltf::buffer::Source::Bin => Ok(None),
178            gltf::buffer::Source::Uri(uri) if uri.starts_with("data:") => {
179                tracing::warn!("data: buffer URIs are not supported yet, skipping buffer {uri}");
180                Ok(None)
181            }
182            gltf::buffer::Source::Uri(uri) => std::fs::read(base.join(uri))
183                .map(Some)
184                .map_err(GltfLoadError::Io),
185        })
186        .collect::<Result<_, _>>()?;
187
188    Ok(node_tree_from_gltf(&gltf.document, |buffer| {
189        match buffer.source() {
190            gltf::buffer::Source::Bin => blob.as_deref(),
191            gltf::buffer::Source::Uri(_) => external
192                .get(buffer.index())
193                .and_then(|data| data.as_deref()),
194        }
195    }))
196}
197
198/// Builds the node tree of the default glTF scene as [`Node`]s of
199/// [`MeshItem`]s, wrapped in a [`GltfTree`] with the document-index →
200/// tree-path mapping.
201///
202/// `get_buffer_data` resolves a glTF buffer to its byte slice; returning
203/// `None` makes the affected accessors unreadable, so primitives fall back to
204/// their documented defaults. The bound is higher-ranked over the buffer's
205/// lifetime (glTF's `Primitive::reader` ties its own borrow to the call);
206/// the returned slice only borrows the resolver's own storage (e.g. a GLB
207/// blob), never the document. See the [module docs](self) for the full
208/// mapping and the POC limitations.
209pub fn node_tree_from_gltf<'s, F>(doc: &gltf::Document, get_buffer_data: F) -> GltfTree
210where
211    F: Clone + for<'b> Fn(gltf::buffer::Buffer<'b>) -> Option<&'s [u8]>,
212{
213    let mut node_paths = vec![None; doc.nodes().count()];
214    let scene = match doc.default_scene().or_else(|| doc.scenes().next()) {
215        Some(scene) => scene,
216        None => {
217            tracing::warn!("glTF document has no scene, importing an empty tree");
218            return GltfTree {
219                tree: Node::frame(),
220                node_paths,
221            };
222        }
223    };
224    let children = scene
225        .nodes()
226        .enumerate()
227        .map(|(slot, node)| {
228            let mut wrapper =
229                node_tree_from_gltf_node(node, &[slot], &mut node_paths, &get_buffer_data);
230            wrapper.compose_outer(y_up_to_z_up());
231            wrapper
232        })
233        .collect::<Vec<_>>();
234    GltfTree {
235        tree: Node::group(children),
236        node_paths,
237    }
238}
239
240/// Converts one glTF node (recursively, via [`gltf::scene::Node::children`]).
241///
242/// `path` is the index path of the node being converted; every visited
243/// node's document index (see [`gltf::scene::Node::index`]) is recorded in
244/// `node_paths`.
245fn node_tree_from_gltf_node<'s, F>(
246    node: gltf::scene::Node<'_>,
247    path: &[usize],
248    node_paths: &mut Vec<Option<Vec<usize>>>,
249    get_buffer_data: &F,
250) -> Transformed<Node<MeshItem>, DAffine3>
251where
252    F: Clone + for<'b> Fn(gltf::buffer::Buffer<'b>) -> Option<&'s [u8]>,
253{
254    let transform = node_transform(node.transform());
255    let mut id = non_empty(node.name()).map(str::to_string);
256
257    let mut children: Vec<Transformed<Node<MeshItem>, DAffine3>> = Vec::new();
258    let mut item: Option<MeshItem> = None;
259    if let Some(mesh) = node.mesh() {
260        let primitive_count = mesh.primitives().count();
261        if primitive_count == 1 {
262            let (index, primitive) = mesh.primitives().enumerate().next().unwrap();
263            // The common case maps natively: the node carries its single
264            // primitive as the payload, exactly like the glTF node carries
265            // its mesh. A node without a name of its own inherits the
266            // mesh's.
267            warn_if_not_triangles(&primitive, mesh.name(), index);
268            item = Some(primitive_mesh_item(primitive, get_buffer_data));
269            if id.is_none() {
270                id = non_empty(mesh.name()).map(str::to_string);
271            }
272        } else {
273            // Known debt (narrowed to multi-primitive meshes): the
274            // primitives become sibling leaves placed before the children.
275            for (index, primitive) in mesh.primitives().enumerate() {
276                warn_if_not_triangles(&primitive, mesh.name(), index);
277                let leaf = Node::leaf(primitive_mesh_item(primitive, get_buffer_data));
278                children.push(
279                    match primitive_leaf_id(mesh.name(), node.name(), index) {
280                        Some(id) => leaf.with_id(id),
281                        None => leaf,
282                    }
283                    .into(),
284                );
285            }
286        }
287    }
288    let payload_slots = children.len();
289    children.extend(node.children().enumerate().map(|(slot, child)| {
290        let mut child_path = path.to_vec();
291        child_path.push(payload_slots + slot);
292        node_tree_from_gltf_node(child, &child_path, node_paths, get_buffer_data)
293    }));
294
295    node_paths[node.index()] = Some(path.to_vec());
296    let node = Node::new(item, children);
297    match id {
298        Some(id) => Transformed::new(node.with_id(id), transform),
299        None => Transformed::new(node, transform),
300    }
301}
302
303/// Warns when a primitive's drawing mode is not `TRIANGLES` (its indices
304/// are imported as-is; no re-triangulation happens in the POC).
305fn warn_if_not_triangles(
306    primitive: &gltf::mesh::Primitive<'_>,
307    mesh_name: Option<&str>,
308    index: usize,
309) {
310    if primitive.mode() != gltf::mesh::Mode::Triangles {
311        tracing::warn!(
312            "primitive {index} of mesh {mesh_name:?} is not TRIANGLES, importing indices as-is"
313        );
314    }
315}
316
317/// Converts one glTF primitive to a [`MeshItem`].
318fn primitive_mesh_item<'s, F>(primitive: gltf::mesh::Primitive<'_>, get_buffer_data: &F) -> MeshItem
319where
320    F: Clone + for<'b> Fn(gltf::buffer::Buffer<'b>) -> Option<&'s [u8]>,
321{
322    let reader = primitive.reader(get_buffer_data);
323
324    let points: Vec<DVec3> = match reader.read_positions() {
325        Some(positions) => positions
326            .map(|p| dvec3(p[0] as f64, p[1] as f64, p[2] as f64))
327            .collect(),
328        None => {
329            tracing::warn!("primitive without POSITION attribute, importing no vertices");
330            Vec::new()
331        }
332    };
333    // Non-indexed glTF primitives draw their vertices as consecutive
334    // triangles; synthesize the identity indexing because an index-less
335    // MeshItem would mean a point cloud.
336    let triangle_indices: Vec<u32> = match reader.read_indices() {
337        Some(indices) => indices.into_u32().collect(),
338        None => (0..points.len() as u32).collect(),
339    };
340    // Absent normals stay all-zero: MeshItem's contract for flat shading.
341    let vertex_normals: Vec<DVec3> = match reader.read_normals() {
342        Some(normals) => normals
343            .map(|n| dvec3(n[0] as f64, n[1] as f64, n[2] as f64))
344            .collect(),
345        None => vec![DVec3::ZERO; points.len()],
346    };
347    // Absent colors keep the MeshItem default (matching
348    // MeshItem::from_indexed_vertices); 3-component colors get alpha 1.0.
349    let vertex_colors: Vec<Rgba> = match reader.read_colors(0) {
350        Some(colors) => colors
351            .into_rgba_f32()
352            .map(|rgba| Rgba(Vec4::from_array(rgba)))
353            .collect(),
354        None => vec![Rgba::default(); points.len()],
355    };
356
357    MeshItem {
358        points: points.into(),
359        triangle_indices,
360        vertex_colors: vertex_colors.into(),
361        vertex_normals: vertex_normals.into(),
362    }
363}
364
365/// Converts a glTF node transform to a [`DAffine3`]: the 4x4 matrix as-is, or
366/// the decomposed `T * R * S` (glTF semantics; the glTF crate's
367/// [`gltf::scene::Transform::matrix`] uses the same equation).
368fn node_transform(transform: gltf::scene::Transform) -> DAffine3 {
369    match transform {
370        gltf::scene::Transform::Matrix { matrix } => {
371            let matrix = matrix.map(|col| col.map(|v| v as f64));
372            DAffine3::from_mat4(DMat4::from_cols_array_2d(&matrix))
373        }
374        gltf::scene::Transform::Decomposed {
375            translation,
376            rotation,
377            scale,
378        } => {
379            let rotation = DQuat::from_xyzw(
380                rotation[0] as f64,
381                rotation[1] as f64,
382                rotation[2] as f64,
383                rotation[3] as f64,
384            );
385            let translation = dvec3(
386                translation[0] as f64,
387                translation[1] as f64,
388                translation[2] as f64,
389            );
390            let scale = dvec3(scale[0] as f64, scale[1] as f64, scale[2] as f64);
391            DAffine3::from_rotation_translation(rotation, translation) * DAffine3::from_scale(scale)
392        }
393    }
394}
395
396/// The id of the leaf holding primitive `index` of a mesh on a node: the mesh
397/// name when present (disambiguated by index), else the node name, else none.
398fn primitive_leaf_id(
399    mesh_name: Option<&str>,
400    node_name: Option<&str>,
401    index: usize,
402) -> Option<String> {
403    match non_empty(mesh_name) {
404        Some(mesh_name) => Some(format!("{mesh_name}.{index}")),
405        None => non_empty(node_name).map(|node_name| format!("{node_name}.primitive{index}")),
406    }
407}
408
409/// `None` for empty strings, so blank glTF names do not become ids.
410fn non_empty(name: Option<&str>) -> Option<&str> {
411    name.filter(|name| !name.is_empty())
412}
413
414#[cfg(test)]
415mod tests {
416    use ranim_core::frame::EvaluatedFrame;
417
418    use super::*;
419
420    fn push_f32(buf: &mut Vec<u8>, value: f32) {
421        buf.extend_from_slice(&value.to_le_bytes());
422    }
423
424    /// The raw 132-byte buffer payload: positions, normals, colors, indices.
425    fn triangle_bin() -> Vec<u8> {
426        let mut bin = Vec::new();
427        for p in [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] {
428            for c in p {
429                push_f32(&mut bin, c);
430            }
431        }
432        for _ in 0..3 {
433            for c in [0.0f32, 0.0, 1.0] {
434                push_f32(&mut bin, c);
435            }
436        }
437        for rgba in [
438            [1.0f32, 0.0, 0.0, 1.0],
439            [0.0, 1.0, 0.0, 1.0],
440            [0.0, 0.0, 1.0, 1.0],
441        ] {
442            for c in rgba {
443                push_f32(&mut bin, c);
444            }
445        }
446        for i in [0u32, 1, 2] {
447            bin.extend_from_slice(&i.to_le_bytes());
448        }
449        bin
450    }
451
452    /// The document for the triangle above; `uri` switches the buffer from
453    /// GLB-embedded (`None`) to an external file reference (`Some`).
454    fn triangle_json(uri: Option<&str>) -> String {
455        let buffers = match uri {
456            Some(uri) => format!(r#"{{"uri": "{uri}", "byteLength": 132}}"#),
457            None => r#"{"byteLength": 132}"#.to_string(),
458        };
459        r#"
460        {
461            "asset": {"version": "2.0"},
462            "scene": 0,
463            "scenes": [{"nodes": [0]}],
464            "nodes": [
465                {"name": "parent", "translation": [1.0, 2.0, 3.0],
466                 "rotation": [0.0, 0.0, 0.7071067811865476, 0.7071067811865476],
467                 "children": [1]},
468                {"name": "child", "scale": [2.0, 2.0, 2.0], "mesh": 0}
469            ],
470            "meshes": [{"name": "tri", "primitives": [{
471                "attributes": {"POSITION": 0, "NORMAL": 1, "COLOR_0": 2},
472                "indices": 3
473            }]}],
474            "buffers": [[BUFFERS]],
475            "bufferViews": [
476                {"buffer": 0, "byteOffset": 0, "byteLength": 36},
477                {"buffer": 0, "byteOffset": 36, "byteLength": 36},
478                {"buffer": 0, "byteOffset": 72, "byteLength": 48},
479                {"buffer": 0, "byteOffset": 120, "byteLength": 12}
480            ],
481            "accessors": [
482                {"bufferView": 0, "componentType": 5126, "count": 3,
483                 "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
484                {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"},
485                {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC4"},
486                {"bufferView": 3, "componentType": 5125, "count": 3, "type": "SCALAR"}
487            ]
488        }"#
489        .replace("[BUFFERS]", &buffers)
490    }
491
492    /// Packs a JSON document and buffer payload into a GLB container.
493    fn pack_glb(json: &str, bin: &[u8]) -> Vec<u8> {
494        let mut json_chunk = json.as_bytes().to_vec();
495        while !json_chunk.len().is_multiple_of(4) {
496            json_chunk.push(b' ');
497        }
498        let mut bin_chunk = bin.to_vec();
499        while !bin_chunk.len().is_multiple_of(4) {
500            bin_chunk.push(0);
501        }
502
503        let total = 12 + 8 + json_chunk.len() + 8 + bin_chunk.len();
504        let mut glb = Vec::with_capacity(total);
505        glb.extend_from_slice(&0x46546C67u32.to_le_bytes()); // "glTF"
506        glb.extend_from_slice(&2u32.to_le_bytes());
507        glb.extend_from_slice(&(total as u32).to_le_bytes());
508        glb.extend_from_slice(&(json_chunk.len() as u32).to_le_bytes());
509        glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes()); // "JSON"
510        glb.extend_from_slice(&json_chunk);
511        glb.extend_from_slice(&(bin_chunk.len() as u32).to_le_bytes());
512        glb.extend_from_slice(&0x004E4942u32.to_le_bytes()); // "BIN\0"
513        glb.extend_from_slice(&bin_chunk);
514        glb
515    }
516
517    /// A minimal GLB: scene → "parent" (T(1,2,3)·Rz(90°)) → "child" (scale 2)
518    /// carrying one triangle mesh with POSITION, NORMAL, COLOR_0, indices.
519    fn triangle_glb() -> Vec<u8> {
520        pack_glb(&triangle_json(None), &triangle_bin())
521    }
522
523    fn triangle_tree() -> GltfTree {
524        let glb = triangle_glb();
525        let gltf = gltf::Gltf::from_slice(&glb).unwrap();
526        let blob = gltf.blob.clone();
527        node_tree_from_gltf(&gltf.document, |buffer| match buffer.source() {
528            gltf::buffer::Source::Bin => blob.as_deref(),
529            gltf::buffer::Source::Uri(_) => None,
530        })
531    }
532
533    #[test]
534    fn nested_mesh_import_preserves_payloads_and_composes_world_poses() {
535        let tree = triangle_tree();
536        let parent = &tree.children()[0];
537        let child = &parent.inner.children()[0];
538        assert_eq!(parent.inner.id.as_deref(), Some("parent"));
539        assert_eq!(child.inner.id.as_deref(), Some("child"));
540        assert!(
541            parent
542                .transform
543                .transform_point3(DVec3::ZERO)
544                .abs_diff_eq(dvec3(1.0, -3.0, 2.0), 1e-6)
545        );
546        assert!((parent.transform.matrix3 * DVec3::X).abs_diff_eq(DVec3::Z, 1e-6));
547        assert!(
548            child
549                .transform
550                .transform_point3(DVec3::ONE)
551                .abs_diff_eq(DVec3::splat(2.0), 1e-9)
552        );
553        let leaf = child.inner.item().unwrap();
554        assert_eq!(leaf.points.as_slice(), &[DVec3::ZERO, DVec3::X, DVec3::Y]);
555        assert_eq!(leaf.triangle_indices, [0, 1, 2]);
556        assert!(
557            leaf.vertex_normals
558                .iter()
559                .all(|normal| normal.abs_diff_eq(DVec3::Z, 1e-6))
560        );
561        assert!(
562            leaf.vertex_colors[0]
563                .0
564                .abs_diff_eq(Vec4::new(1.0, 0.0, 0.0, 1.0), 1e-6)
565        );
566        // S2 -> Rz90 -> T(1,2,3) -> Y-up/Z-up: (1,0,0) becomes (1,-3,4).
567        let (world, _) = tree.leaves().next().unwrap();
568        assert!(
569            world
570                .transform_point3(DVec3::X)
571                .abs_diff_eq(dvec3(1.0, -3.0, 4.0), 1e-6)
572        );
573        let mut frame = EvaluatedFrame::new();
574        frame.push((0, 0), tree.tree);
575        let stored = &frame.items::<Node<MeshItem>>()[0].value;
576        let (placement, mesh) = stored.leaves().next().unwrap();
577        assert_eq!(mesh.points.as_slice(), &[DVec3::ZERO, DVec3::X, DVec3::Y]);
578        assert!(placement.abs_diff_eq(world, 1e-9));
579        assert!(
580            placement
581                .transform_point3(DVec3::X)
582                .abs_diff_eq(dvec3(1.0, -3.0, 4.0), 1e-6)
583        );
584    }
585
586    #[test]
587    fn loads_a_glb_file_from_disk() {
588        let path = std::env::temp_dir().join(format!("ranim_gltf_test_{}.glb", std::process::id()));
589        std::fs::write(&path, triangle_glb()).unwrap();
590        let loaded = node_tree_from_path(&path);
591        let _ = std::fs::remove_file(&path);
592
593        let tree = loaded.unwrap();
594        assert_eq!(tree.leaves().count(), 1);
595        let leaf = tree.children()[0].inner.children()[0].inner.item().unwrap();
596        assert_eq!(leaf.triangle_indices, vec![0, 1, 2]);
597    }
598
599    #[test]
600    fn resolves_external_bin_buffers_next_to_a_gltf() {
601        let dir = std::env::temp_dir();
602        let stem = format!("ranim_gltf_ext_{}", std::process::id());
603        let gltf_path = dir.join(format!("{stem}.gltf"));
604        let bin_path = dir.join(format!("{stem}.bin"));
605        std::fs::write(&gltf_path, triangle_json(Some(&format!("{stem}.bin")))).unwrap();
606        std::fs::write(&bin_path, triangle_bin()).unwrap();
607        let loaded = node_tree_from_path(&gltf_path);
608        let _ = std::fs::remove_file(&gltf_path);
609        let _ = std::fs::remove_file(&bin_path);
610
611        // The vertex data round-trips, proving the external buffer was
612        // resolved relative to the .gltf and read.
613        let tree = loaded.unwrap();
614        let leaf = tree.children()[0].inner.children()[0].inner.item().unwrap();
615        assert_eq!(leaf.triangle_indices, vec![0, 1, 2]);
616        assert_eq!(leaf.points.len(), 3);
617    }
618
619    #[test]
620    fn node_paths_map_document_indices_to_tree_positions() {
621        let tree = triangle_tree();
622
623        // Doc node 0 ("parent") is the scene's only root; doc node 1
624        // ("child") hangs below it. The parent carries no mesh, so the
625        // child sits at slot 0.
626        let parent = tree.node(0).unwrap();
627        assert_eq!(parent.inner.id.as_deref(), Some("parent"));
628        let child = tree.node(1).unwrap();
629        assert_eq!(child.inner.id.as_deref(), Some("child"));
630        assert_eq!(tree.node_paths[0], Some(vec![0]));
631        assert_eq!(tree.node_paths[1], Some(vec![0, 0]));
632
633        // Out-of-range indices resolve to None; node() agrees with get().
634        assert!(tree.node(7).is_none());
635        // Mutable access through the same map.
636        let mut tree = tree;
637        tree.node_mut(1).unwrap().inner.id = Some("renamed".into());
638        assert_eq!(tree.node(1).unwrap().inner.id.as_deref(), Some("renamed"));
639    }
640}