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`](crate::mesh::gltf::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`](crate::mesh::gltf::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`](crate::hierarchy::Node) pose as a [`DAffine3`](ranim_core::glam::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::core_item::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::core_item::CoreItem;
417    use ranim_core::{Extract, glam::Vec3};
418
419    use super::*;
420
421    fn push_f32(buf: &mut Vec<u8>, value: f32) {
422        buf.extend_from_slice(&value.to_le_bytes());
423    }
424
425    /// The raw 132-byte buffer payload: positions, normals, colors, indices.
426    fn triangle_bin() -> Vec<u8> {
427        let mut bin = Vec::new();
428        for p in [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] {
429            for c in p {
430                push_f32(&mut bin, c);
431            }
432        }
433        for _ in 0..3 {
434            for c in [0.0f32, 0.0, 1.0] {
435                push_f32(&mut bin, c);
436            }
437        }
438        for rgba in [
439            [1.0f32, 0.0, 0.0, 1.0],
440            [0.0, 1.0, 0.0, 1.0],
441            [0.0, 0.0, 1.0, 1.0],
442        ] {
443            for c in rgba {
444                push_f32(&mut bin, c);
445            }
446        }
447        for i in [0u32, 1, 2] {
448            bin.extend_from_slice(&i.to_le_bytes());
449        }
450        assert_eq!(bin.len(), 132);
451        bin
452    }
453
454    /// The document for the triangle above; `uri` switches the buffer from
455    /// GLB-embedded (`None`) to an external file reference (`Some`).
456    fn triangle_json(uri: Option<&str>) -> String {
457        let buffers = match uri {
458            Some(uri) => format!(r#"{{"uri": "{uri}", "byteLength": 132}}"#),
459            None => r#"{"byteLength": 132}"#.to_string(),
460        };
461        r#"
462        {
463            "asset": {"version": "2.0"},
464            "scene": 0,
465            "scenes": [{"nodes": [0]}],
466            "nodes": [
467                {"name": "parent", "translation": [1.0, 2.0, 3.0],
468                 "rotation": [0.0, 0.0, 0.7071067811865476, 0.7071067811865476],
469                 "children": [1]},
470                {"name": "child", "scale": [2.0, 2.0, 2.0], "mesh": 0}
471            ],
472            "meshes": [{"name": "tri", "primitives": [{
473                "attributes": {"POSITION": 0, "NORMAL": 1, "COLOR_0": 2},
474                "indices": 3
475            }]}],
476            "buffers": [[BUFFERS]],
477            "bufferViews": [
478                {"buffer": 0, "byteOffset": 0, "byteLength": 36},
479                {"buffer": 0, "byteOffset": 36, "byteLength": 36},
480                {"buffer": 0, "byteOffset": 72, "byteLength": 48},
481                {"buffer": 0, "byteOffset": 120, "byteLength": 12}
482            ],
483            "accessors": [
484                {"bufferView": 0, "componentType": 5126, "count": 3,
485                 "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
486                {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"},
487                {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC4"},
488                {"bufferView": 3, "componentType": 5125, "count": 3, "type": "SCALAR"}
489            ]
490        }"#
491        .replace("[BUFFERS]", &buffers)
492    }
493
494    /// Packs a JSON document and buffer payload into a GLB container.
495    fn pack_glb(json: &str, bin: &[u8]) -> Vec<u8> {
496        let mut json_chunk = json.as_bytes().to_vec();
497        while !json_chunk.len().is_multiple_of(4) {
498            json_chunk.push(b' ');
499        }
500        let mut bin_chunk = bin.to_vec();
501        while !bin_chunk.len().is_multiple_of(4) {
502            bin_chunk.push(0);
503        }
504
505        let total = 12 + 8 + json_chunk.len() + 8 + bin_chunk.len();
506        let mut glb = Vec::with_capacity(total);
507        glb.extend_from_slice(&0x46546C67u32.to_le_bytes()); // "glTF"
508        glb.extend_from_slice(&2u32.to_le_bytes());
509        glb.extend_from_slice(&(total as u32).to_le_bytes());
510        glb.extend_from_slice(&(json_chunk.len() as u32).to_le_bytes());
511        glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes()); // "JSON"
512        glb.extend_from_slice(&json_chunk);
513        glb.extend_from_slice(&(bin_chunk.len() as u32).to_le_bytes());
514        glb.extend_from_slice(&0x004E4942u32.to_le_bytes()); // "BIN\0"
515        glb.extend_from_slice(&bin_chunk);
516        glb
517    }
518
519    /// A minimal GLB: scene → "parent" (T(1,2,3)·Rz(90°)) → "child" (scale 2)
520    /// carrying one triangle mesh with POSITION, NORMAL, COLOR_0, indices.
521    fn triangle_glb() -> Vec<u8> {
522        pack_glb(&triangle_json(None), &triangle_bin())
523    }
524
525    fn triangle_tree() -> GltfTree {
526        let glb = triangle_glb();
527        let gltf = gltf::Gltf::from_slice(&glb).unwrap();
528        let blob = gltf.blob.clone();
529        node_tree_from_gltf(&gltf.document, |buffer| match buffer.source() {
530            gltf::buffer::Source::Bin => blob.as_deref(),
531            gltf::buffer::Source::Uri(_) => None,
532        })
533    }
534
535    #[test]
536    fn tree_shape_mirrors_the_gltf_node_graph() {
537        let tree = triangle_tree();
538        assert!(tree.is_group());
539        let roots = tree.children();
540        assert_eq!(roots.len(), 1);
541
542        let parent = &roots[0];
543        assert_eq!(parent.inner.id.as_deref(), Some("parent"));
544        assert!(parent.inner.is_group());
545        let parent_children = parent.inner.children();
546        assert_eq!(parent_children.len(), 1);
547
548        let child = &parent_children[0];
549        // A single-primitive mesh maps natively to the node's payload.
550        assert_eq!(child.inner.id.as_deref(), Some("child"));
551        assert!(child.inner.is_leaf());
552        assert_eq!(child.inner.item().unwrap().triangle_indices, vec![0, 1, 2]);
553    }
554
555    #[test]
556    fn node_transforms_match_trs_semantics() {
557        let tree = triangle_tree();
558        let parent = &tree.children()[0];
559        let child = &parent.inner.children()[0];
560
561        // Parent: the scene-root wrapper's pose is the loader's Y-up → Z-up
562        // flip composed outside the node's own T(1,2,3)·Rz(+90deg), so ZERO
563        // lands at flip(1,2,3) = (1,-3,4) and the node's X axis maps
564        // X -> Y -> Z. Rotation-derived values carry ~1e-7 f32 noise from
565        // the glTF source, so their tolerance is 1e-6 (translations/scales
566        // stay exact).
567        let parent_t = &parent.transform;
568        assert!(
569            parent_t
570                .transform_point3(DVec3::ZERO)
571                .abs_diff_eq(dvec3(1.0, -3.0, 2.0), 1e-6)
572        );
573        assert!((parent_t.matrix3 * DVec3::X).abs_diff_eq(DVec3::Z, 1e-6));
574
575        // Child: pure uniform scale of 2.
576        assert!(
577            child
578                .transform
579                .transform_point3(dvec3(1.0, 1.0, 1.0))
580                .abs_diff_eq(dvec3(2.0, 2.0, 2.0), 1e-9)
581        );
582    }
583
584    #[test]
585    fn leaf_mesh_round_trips_primitive_data() {
586        let tree = triangle_tree();
587        let leaf = tree.children()[0].inner.children()[0].inner.item().unwrap();
588
589        let points: Vec<DVec3> = leaf.points.iter().cloned().collect();
590        assert_eq!(
591            points,
592            vec![
593                dvec3(0.0, 0.0, 0.0),
594                dvec3(1.0, 0.0, 0.0),
595                dvec3(0.0, 1.0, 0.0)
596            ]
597        );
598        assert_eq!(leaf.triangle_indices, vec![0, 1, 2]);
599        assert!(
600            leaf.vertex_normals
601                .iter()
602                .all(|n| n.abs_diff_eq(DVec3::Z, 1e-6))
603        );
604        let first_color = leaf.vertex_colors[0].0;
605        assert!((first_color - Vec4::new(1.0, 0.0, 0.0, 1.0)).abs_diff_eq(Vec4::ZERO, 1e-6));
606    }
607
608    #[test]
609    fn extraction_composes_the_world_transform_for_meshes() {
610        let tree = triangle_tree();
611
612        // leaves() yields the full f64 chain:
613        // flip(Y-up->Z-up) * parent(T·R) * child(S2).
614        // (1,0,0) -> S2 (2,0,0) -> Rz90 (0,2,0) -> T (1,4,3) -> flip (1,-3,4).
615        // f32 source noise puts the 1e-6 tolerance on rotation contributions.
616        let (world, _) = tree.leaves().next().unwrap();
617        assert!(
618            world
619                .transform_point3(dvec3(1.0, 0.0, 0.0))
620                .abs_diff_eq(dvec3(1.0, -3.0, 4.0), 1e-6)
621        );
622
623        // Extraction bakes the same chain into the core item's transform.
624        let extracted = tree.extract();
625        assert_eq!(extracted.len(), 1);
626        match &extracted[0] {
627            CoreItem::MeshItem(mesh) => {
628                let local = Vec3::new(1.0, 0.0, 0.0);
629                let world = mesh.transform.transform_point3(local);
630                assert!((world - Vec3::new(1.0, -3.0, 4.0)).length() < 1e-4);
631                // Vertex data stays local.
632                assert_eq!(mesh.points.len(), 3);
633            }
634            _ => panic!("expected a MeshItem"),
635        }
636    }
637
638    #[test]
639    fn loads_a_glb_file_from_disk() {
640        let path = std::env::temp_dir().join(format!("ranim_gltf_test_{}.glb", std::process::id()));
641        std::fs::write(&path, triangle_glb()).unwrap();
642        let loaded = node_tree_from_path(&path);
643        let _ = std::fs::remove_file(&path);
644
645        let tree = loaded.unwrap();
646        assert_eq!(tree.leaves().count(), 1);
647        let leaf = tree.children()[0].inner.children()[0].inner.item().unwrap();
648        assert_eq!(leaf.triangle_indices, vec![0, 1, 2]);
649    }
650
651    #[test]
652    fn resolves_external_bin_buffers_next_to_a_gltf() {
653        let dir = std::env::temp_dir();
654        let stem = format!("ranim_gltf_ext_{}", std::process::id());
655        let gltf_path = dir.join(format!("{stem}.gltf"));
656        let bin_path = dir.join(format!("{stem}.bin"));
657        std::fs::write(&gltf_path, triangle_json(Some(&format!("{stem}.bin")))).unwrap();
658        std::fs::write(&bin_path, triangle_bin()).unwrap();
659        let loaded = node_tree_from_path(&gltf_path);
660        let _ = std::fs::remove_file(&gltf_path);
661        let _ = std::fs::remove_file(&bin_path);
662
663        // The vertex data round-trips, proving the external buffer was
664        // resolved relative to the .gltf and read.
665        let tree = loaded.unwrap();
666        let leaf = tree.children()[0].inner.children()[0].inner.item().unwrap();
667        assert_eq!(leaf.triangle_indices, vec![0, 1, 2]);
668        assert_eq!(leaf.points.len(), 3);
669    }
670
671    #[test]
672    fn node_paths_map_document_indices_to_tree_positions() {
673        let tree = triangle_tree();
674
675        // Doc node 0 ("parent") is the scene's only root; doc node 1
676        // ("child") hangs below it. The parent carries no mesh, so the
677        // child sits at slot 0.
678        let parent = tree.node(0).unwrap();
679        assert_eq!(parent.inner.id.as_deref(), Some("parent"));
680        let child = tree.node(1).unwrap();
681        assert_eq!(child.inner.id.as_deref(), Some("child"));
682        assert_eq!(tree.node_paths[0], Some(vec![0]));
683        assert_eq!(tree.node_paths[1], Some(vec![0, 0]));
684
685        // Out-of-range indices resolve to None; node() agrees with get().
686        assert!(tree.node(7).is_none());
687        assert_eq!(
688            tree.node(1).unwrap().inner.id,
689            tree.get(&tree.node_paths[1].clone().unwrap())
690                .unwrap()
691                .inner
692                .id
693        );
694
695        // Mutable access through the same map.
696        let mut tree = triangle_tree();
697        tree.node_mut(1).unwrap().inner.id = Some("renamed".into());
698        assert_eq!(tree.node(1).unwrap().inner.id.as_deref(), Some("renamed"));
699    }
700}