Skip to main content

animsmith_gltf/
write.rs

1//! Minimal glTF 2.0 writer for `convert`/`transform`: emits the
2//! skeleton (node hierarchy + rest TRS), each clip's writable animation tracks,
3//! and whatever scene assets the [`Document`] carries ([`Document::assets`]
4//! — triangulated meshes, skins, factor-only materials, and embedded
5//! base-color textures). A document with default-empty assets writes
6//! animation + skeleton only, so
7//! animation data can still enter glTF-based tooling (including animsmith
8//! itself) straight from a DCC export.
9//!
10//! Values are written exactly as held in the core model — lint first;
11//! conversion does not repair.
12
13use crate::WriteError;
14use animsmith_core::model::{Document, Interpolation, Property, TrackValues};
15use base64::Engine as _;
16use serde_json::{Value, json};
17use std::path::Path;
18
19/// Counts of the scene data emitted by [`write()`].
20///
21/// The first five fields describe the generated glTF, which can differ from the
22/// input [`Document`] when an animation clip has no writable channels, a
23/// skinned mesh requires an additional holder node, or materials are omitted
24/// because the document has no meshes. [`Self::clips_without_writable_tracks`]
25/// is intentionally an input-to-output delta rather than an artifact count so
26/// callers can explain omitted source clips.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct WriteSummary {
30    /// Number of nodes emitted in the glTF skeleton/scene graph.
31    pub nodes: usize,
32    /// Number of animations emitted.
33    pub animations: usize,
34    /// Number of meshes emitted.
35    pub meshes: usize,
36    /// Number of primitive positions emitted.
37    pub primitive_positions: usize,
38    /// Number of materials emitted.
39    pub materials: usize,
40    /// Number of input clips omitted because none of their tracks were writable.
41    pub clips_without_writable_tracks: usize,
42}
43
44struct BufferBuilder {
45    bytes: Vec<u8>,
46    views: Vec<Value>,
47    accessors: Vec<Value>,
48}
49
50impl BufferBuilder {
51    fn new() -> Self {
52        Self {
53            bytes: Vec::new(),
54            views: Vec::new(),
55            accessors: Vec::new(),
56        }
57    }
58
59    /// Append `data` as a buffer view + accessor; returns the accessor
60    /// index. `kind` is "SCALAR" | "VEC3" | "VEC4"; floats only.
61    fn push(&mut self, data: &[f32], kind: &str, with_min_max: bool) -> usize {
62        let components = match kind {
63            "SCALAR" => 1,
64            "VEC2" => 2,
65            "VEC3" => 3,
66            "MAT4" => 16,
67            _ => 4,
68        };
69        let offset = self.bytes.len();
70        for v in data {
71            self.bytes.extend_from_slice(&v.to_le_bytes());
72        }
73        let view = self.views.len();
74        self.views.push(json!({
75            "buffer": 0,
76            "byteOffset": offset,
77            "byteLength": data.len() * 4,
78        }));
79        let mut accessor = json!({
80            "bufferView": view,
81            "componentType": 5126,
82            "count": data.len() / components,
83            "type": kind,
84        });
85        if with_min_max && !data.is_empty() {
86            // Required on animation inputs; componentwise for vectors.
87            let mut min = vec![f32::MAX; components];
88            let mut max = vec![f32::MIN; components];
89            for (i, v) in data.iter().enumerate() {
90                let c = i % components;
91                min[c] = min[c].min(*v);
92                max[c] = max[c].max(*v);
93            }
94            accessor["min"] = json!(min);
95            accessor["max"] = json!(max);
96        }
97        let index = self.accessors.len();
98        self.accessors.push(accessor);
99        index
100    }
101}
102
103impl BufferBuilder {
104    /// Append u32 triangle indices as a buffer view + accessor.
105    fn push_indices(&mut self, data: &[u32]) -> usize {
106        let offset = self.bytes.len();
107        for v in data {
108            self.bytes.extend_from_slice(&v.to_le_bytes());
109        }
110        let view = self.views.len();
111        self.views.push(json!({
112            "buffer": 0,
113            "byteOffset": offset,
114            "byteLength": data.len() * 4,
115        }));
116        let index = self.accessors.len();
117        self.accessors.push(json!({
118            "bufferView": view,
119            "componentType": 5125,
120            "count": data.len(),
121            "type": "SCALAR",
122        }));
123        index
124    }
125
126    /// Append raw bytes (an encoded image) as a bare buffer view.
127    fn push_view(&mut self, data: &[u8]) -> usize {
128        while !self.bytes.len().is_multiple_of(4) {
129            self.bytes.push(0);
130        }
131        let offset = self.bytes.len();
132        self.bytes.extend_from_slice(data);
133        let view = self.views.len();
134        self.views.push(json!({
135            "buffer": 0,
136            "byteOffset": offset,
137            "byteLength": data.len(),
138        }));
139        view
140    }
141
142    /// Append u16 data (JOINTS_0) as a buffer view + accessor.
143    fn push_u16(&mut self, data: &[u16], kind: &str) -> usize {
144        let components = if kind == "VEC4" { 4 } else { 1 };
145        let offset = self.bytes.len();
146        for v in data {
147            self.bytes.extend_from_slice(&v.to_le_bytes());
148        }
149        while !self.bytes.len().is_multiple_of(4) {
150            self.bytes.push(0);
151        }
152        let view = self.views.len();
153        self.views.push(json!({
154            "buffer": 0,
155            "byteOffset": offset,
156            "byteLength": data.len() * 2,
157        }));
158        let index = self.accessors.len();
159        self.accessors.push(json!({
160            "bufferView": view,
161            "componentType": 5123,
162            "count": data.len() / components,
163            "type": kind,
164        }));
165        index
166    }
167}
168
169fn document_to_json(doc: &Document, buffer_uri: Option<String>, buffer_len: usize) -> Value {
170    let mut nodes: Vec<Value> = Vec::with_capacity(doc.skeleton.bones.len());
171    for (id, bone) in doc.skeleton.bones.iter().enumerate() {
172        let children: Vec<usize> = doc
173            .skeleton
174            .bones
175            .iter()
176            .enumerate()
177            .filter(|(_, b)| b.parent == Some(id))
178            .map(|(i, _)| i)
179            .collect();
180        let mut node = json!({
181            "name": bone.name,
182            "translation": bone.rest.translation.to_array(),
183            "rotation": bone.rest.rotation.to_array(),
184            "scale": bone.rest.scale.to_array(),
185        });
186        if !children.is_empty() {
187            node["children"] = json!(children);
188        }
189        nodes.push(node);
190    }
191    let roots: Vec<usize> = doc
192        .skeleton
193        .bones
194        .iter()
195        .enumerate()
196        .filter(|(_, b)| b.parent.is_none())
197        .map(|(i, _)| i)
198        .collect();
199
200    let mut root = json!({
201        "asset": {
202            "version": "2.0",
203            "generator": format!("animsmith {}", env!("CARGO_PKG_VERSION")),
204        },
205        "scene": 0,
206        "scenes": [{ "nodes": roots }],
207        "nodes": nodes,
208    });
209    // A glTF buffer must have byteLength ≥ 1. An empty document (no
210    // animation, no mesh bytes) has nothing to reference it — and no
211    // bufferViews or accessors either — so omit the buffer rather than
212    // emit a zero-length one, which in GLB would force an empty BIN chunk
213    // the Khronos validator rejects (GLB_EMPTY_CHUNK). The caller
214    // likewise omits the (empty) bufferViews/accessors arrays.
215    if buffer_len > 0 {
216        let mut buffer = json!({ "byteLength": buffer_len });
217        if let Some(uri) = buffer_uri {
218            buffer["uri"] = json!(uri);
219        }
220        root["buffers"] = json!([buffer]);
221    }
222    root
223}
224
225/// Narrow a GLB byte length to the `u32` its header/chunk field requires,
226/// failing closed above the 4 GiB GLB limit rather than truncating (which
227/// would emit a length field disagreeing with the bytes on disk).
228fn glb_len_u32(field: &'static str, len: usize) -> Result<u32, WriteError> {
229    u32::try_from(len).map_err(|_| WriteError::TooLarge { field, bytes: len })
230}
231
232/// The `u32` length fields of a GLB container.
233#[derive(Debug)]
234struct GlbLengths {
235    /// Total file length (12-byte header + JSON chunk + optional BIN chunk).
236    total: u32,
237    /// JSON chunk payload length.
238    json: u32,
239    /// BIN chunk payload length, or `None` when the payload is empty (the
240    /// BIN chunk is then omitted — an empty chunk is GLB_EMPTY_CHUNK).
241    bin: Option<u32>,
242}
243
244/// Plan a GLB's chunk framing from its (already 4-byte-padded) JSON and
245/// BIN payload lengths, narrowing every `u32` length field and failing
246/// closed above the 4 GiB GLB limit. The parts are checked *before* the
247/// total so an oversized JSON or BIN chunk is attributed to itself rather
248/// than masked as a total overflow (each part is `<= total`, so a
249/// total-first check could only ever report `total`).
250fn plan_glb_lengths(json_len: usize, bin_len: usize) -> Result<GlbLengths, WriteError> {
251    let json = glb_len_u32("JSON chunk", json_len)?;
252    let (bin, bin_bytes) = if bin_len > 0 {
253        (Some(glb_len_u32("BIN chunk", bin_len)?), 8 + bin_len)
254    } else {
255        (None, 0)
256    };
257    let total = glb_len_u32("total GLB length", 12 + 8 + json_len + bin_bytes)?;
258    Ok(GlbLengths { total, json, bin })
259}
260
261/// Serialize `doc` to `path` (`.glb` for binary, anything else as
262/// `.gltf` JSON with an embedded data-URI buffer): skeleton, animation,
263/// and any scene assets it carries ([`Document::assets`] — triangulated
264/// meshes, skins, factor-only materials, and embedded PNG/JPEG base-color
265/// textures). A `Document` with default-empty assets writes animation and
266/// skeleton only.
267///
268/// # Errors
269///
270/// Returns [`WriteError::Serialize`] if the generated glTF JSON cannot be
271/// serialized, [`WriteError::TooLarge`] if a GLB length field would exceed
272/// the format's 4 GiB `u32` limit, and [`WriteError::Io`] when the output
273/// file cannot be written.
274pub fn write(doc: &Document, path: &Path) -> Result<WriteSummary, WriteError> {
275    let assets = &doc.assets;
276    let mut buffers = BufferBuilder::new();
277    let mut animations: Vec<Value> = Vec::new();
278    let mut clips_without_writable_tracks = 0usize;
279
280    for clip in &doc.clips {
281        let mut samplers: Vec<Value> = Vec::new();
282        let mut channels: Vec<Value> = Vec::new();
283        for track in &clip.tracks {
284            if track.times.is_empty() || track.bone >= doc.skeleton.bones.len() {
285                continue;
286            }
287            let input = buffers.push(&track.times, "SCALAR", true);
288            let output = match &track.values {
289                TrackValues::Vec3s(v) => {
290                    let flat: Vec<f32> = v.iter().flat_map(|x| x.to_array()).collect();
291                    buffers.push(&flat, "VEC3", false)
292                }
293                TrackValues::Quats(v) => {
294                    let flat: Vec<f32> = v.iter().flat_map(|q| q.to_array()).collect();
295                    buffers.push(&flat, "VEC4", false)
296                }
297            };
298            let interpolation = match track.interpolation {
299                Interpolation::Linear => "LINEAR",
300                Interpolation::Step => "STEP",
301                Interpolation::CubicSpline => "CUBICSPLINE",
302            };
303            let target_path = match track.property {
304                Property::Translation => "translation",
305                Property::Rotation => "rotation",
306                Property::Scale => "scale",
307            };
308            let sampler = samplers.len();
309            samplers.push(json!({
310                "input": input,
311                "output": output,
312                "interpolation": interpolation,
313            }));
314            channels.push(json!({
315                "sampler": sampler,
316                "target": {
317                    "node": track.bone,
318                    "path": target_path,
319                },
320            }));
321        }
322        if channels.is_empty() {
323            clips_without_writable_tracks += 1;
324        } else {
325            animations.push(json!({
326                "name": clip.name,
327                "samplers": samplers,
328                "channels": channels,
329            }));
330        }
331    }
332
333    let mut meshes_json: Vec<Value> = Vec::new();
334    let mut skins_json: Vec<Value> = Vec::new();
335    // node index -> (mesh index, Option<skin index>)
336    let mut node_attach: Vec<(usize, usize, Option<usize>)> = Vec::new();
337    for mesh in &assets.meshes {
338        let mut prims: Vec<Value> = Vec::new();
339        for prim in &mesh.primitives {
340            let flat: Vec<f32> = prim.positions.iter().flat_map(|v| v.to_array()).collect();
341            let mut attributes = json!({
342                // POSITION min/max is required by the spec.
343                "POSITION": buffers.push(&flat, "VEC3", true),
344            });
345            if !prim.normals.is_empty() {
346                let flat: Vec<f32> = prim.normals.iter().flat_map(|v| v.to_array()).collect();
347                attributes["NORMAL"] = json!(buffers.push(&flat, "VEC3", false));
348            }
349            if !prim.uvs.is_empty() {
350                let flat: Vec<f32> = prim.uvs.iter().flatten().copied().collect();
351                attributes["TEXCOORD_0"] = json!(buffers.push(&flat, "VEC2", false));
352            }
353            if !prim.joints.is_empty() {
354                let flat_j: Vec<u16> = prim.joints.iter().flatten().copied().collect();
355                attributes["JOINTS_0"] = json!(buffers.push_u16(&flat_j, "VEC4"));
356                let flat_w: Vec<f32> = prim.weights.iter().flatten().copied().collect();
357                attributes["WEIGHTS_0"] = json!(buffers.push(&flat_w, "VEC4", false));
358            }
359            let mut value = json!({ "attributes": attributes });
360            if !prim.indices.is_empty() {
361                value["indices"] = json!(buffers.push_indices(&prim.indices));
362            }
363            if let Some(material) = prim.material {
364                value["material"] = json!(material);
365            }
366            prims.push(value);
367        }
368        let mesh_index = meshes_json.len();
369        meshes_json.push(json!({ "name": mesh.name, "primitives": prims }));
370
371        let skin_index = if mesh.skin_joints.is_empty() {
372            None
373        } else {
374            let mut ibms: Vec<f32> = Vec::with_capacity(mesh.skin_joints.len() * 16);
375            for (slot, &joint) in mesh.skin_joints.iter().enumerate() {
376                let m = mesh
377                    .skin_ibms
378                    .get(slot)
379                    .copied()
380                    .or_else(|| doc.skeleton.bones.get(joint).and_then(|b| b.inverse_bind))
381                    .unwrap_or(glam::Mat4::IDENTITY);
382                ibms.extend_from_slice(&m.to_cols_array());
383            }
384            let accessor = buffers.push(&ibms, "MAT4", false);
385            let index = skins_json.len();
386            skins_json.push(json!({
387                "joints": mesh.skin_joints,
388                "inverseBindMatrices": accessor,
389            }));
390            Some(index)
391        };
392        // Skinned meshes hang off a fresh identity node at scene root:
393        // the spec ignores a skinned mesh's node transform, but several
394        // loaders (notably three.js) fold it into the bind matrix, so a
395        // transform-carrying node yields inconsistent rendering. The
396        // joints + IBMs fully place the vertices. Unskinned meshes keep
397        // their original node, whose transform is meaningful.
398        node_attach.push((mesh.node, mesh_index, skin_index));
399    }
400
401    // Embedded base-color textures: raw encoded bytes as buffer views
402    // (glTF never decodes; PNG/JPEG pass through untouched).
403    let mut images_json: Vec<Value> = Vec::new();
404    let mut textures_json: Vec<Value> = Vec::new();
405    let mut material_texture_index: Vec<Option<usize>> = vec![None; assets.materials.len()];
406    for (mi, material) in assets.materials.iter().enumerate() {
407        if let Some(texture) = &material.base_color_texture {
408            let view = buffers.push_view(&texture.bytes);
409            let image = images_json.len();
410            images_json.push(json!({ "bufferView": view, "mimeType": texture.mime }));
411            material_texture_index[mi] = Some(textures_json.len());
412            textures_json.push(json!({ "source": image }));
413        }
414    }
415
416    let binary = path
417        .extension()
418        .and_then(|e| e.to_str())
419        .is_some_and(|e| e.eq_ignore_ascii_case("glb"));
420
421    let uri = if binary {
422        None
423    } else {
424        Some(format!(
425            "data:application/octet-stream;base64,{}",
426            base64::engine::general_purpose::STANDARD.encode(&buffers.bytes)
427        ))
428    };
429    let mut root = document_to_json(doc, uri, buffers.bytes.len());
430    // Present-but-empty accessor arrays are invalid glTF (minItems 1); an
431    // empty document has none, so emit them only when populated.
432    if !buffers.views.is_empty() {
433        root["bufferViews"] = Value::Array(buffers.views);
434    }
435    if !buffers.accessors.is_empty() {
436        root["accessors"] = Value::Array(buffers.accessors);
437    }
438    if !animations.is_empty() {
439        root["animations"] = Value::Array(animations);
440    }
441    if !meshes_json.is_empty() {
442        for (node, mesh_index, skin_index) in &node_attach {
443            match skin_index {
444                Some(skin) => {
445                    let nodes = root["nodes"].as_array_mut().expect("nodes array");
446                    let holder = nodes.len();
447                    nodes.push(json!({
448                        "name": format!("{}_skinned", assets.meshes[*mesh_index].name),
449                        "mesh": mesh_index,
450                        "skin": skin,
451                    }));
452                    root["scenes"][0]["nodes"]
453                        .as_array_mut()
454                        .expect("scene roots")
455                        .push(json!(holder));
456                }
457                None => {
458                    let node_value = &mut root["nodes"][*node];
459                    node_value["mesh"] = json!(mesh_index);
460                }
461            }
462        }
463        root["meshes"] = Value::Array(meshes_json);
464        if !skins_json.is_empty() {
465            root["skins"] = Value::Array(skins_json);
466        }
467        if !assets.materials.is_empty() {
468            root["materials"] = Value::Array(
469                assets
470                    .materials
471                    .iter()
472                    .enumerate()
473                    .map(|(mi, m)| {
474                        let mut pbr = json!({
475                            "baseColorFactor": m.base_color,
476                            "metallicFactor": m.metallic,
477                            "roughnessFactor": m.roughness,
478                        });
479                        if let Some(slot) = material_texture_index[mi] {
480                            pbr["baseColorTexture"] = json!({ "index": slot });
481                        }
482                        json!({ "name": m.name, "pbrMetallicRoughness": pbr })
483                    })
484                    .collect(),
485            );
486            if !images_json.is_empty() {
487                root["images"] = Value::Array(images_json);
488                root["textures"] = Value::Array(textures_json);
489            }
490        }
491    }
492
493    let array_len = |key: &str| root.get(key).and_then(Value::as_array).map_or(0, Vec::len);
494    let primitive_positions = root
495        .get("meshes")
496        .and_then(Value::as_array)
497        .into_iter()
498        .flatten()
499        .filter_map(|mesh| mesh.get("primitives").and_then(Value::as_array))
500        .flatten()
501        .filter_map(|primitive| {
502            primitive
503                .pointer("/attributes/POSITION")
504                .and_then(Value::as_u64)
505                .and_then(|index| usize::try_from(index).ok())
506        })
507        .filter_map(|index| {
508            root.get("accessors")
509                .and_then(Value::as_array)
510                .and_then(|accessors| accessors.get(index))
511                .and_then(|accessor| accessor.get("count"))
512                .and_then(Value::as_u64)
513                .and_then(|count| usize::try_from(count).ok())
514        })
515        .sum();
516    let animations = array_len("animations");
517    let summary = WriteSummary {
518        nodes: array_len("nodes"),
519        animations,
520        meshes: array_len("meshes"),
521        primitive_positions,
522        materials: array_len("materials"),
523        clips_without_writable_tracks,
524    };
525
526    let io_err = |e: std::io::Error| WriteError::Io {
527        path: path.display().to_string(),
528        source: e,
529    };
530    if binary {
531        let mut json_bytes = serde_json::to_vec(&root)?;
532        while !json_bytes.len().is_multiple_of(4) {
533            json_bytes.push(b' ');
534        }
535        let mut bin = buffers.bytes;
536        while !bin.len().is_multiple_of(4) {
537            bin.push(0);
538        }
539        // Plan and length-check the chunk framing once, so the bytes
540        // emitted below can't diverge from what was checked.
541        let lengths = plan_glb_lengths(json_bytes.len(), bin.len())?;
542        let mut out = Vec::with_capacity(lengths.total as usize);
543        out.extend_from_slice(b"glTF");
544        out.extend_from_slice(&2u32.to_le_bytes());
545        out.extend_from_slice(&lengths.total.to_le_bytes());
546        out.extend_from_slice(&lengths.json.to_le_bytes());
547        out.extend_from_slice(b"JSON");
548        out.extend_from_slice(&json_bytes);
549        // `bin` is `Some` exactly when a non-empty BIN chunk is emitted;
550        // an empty payload omits the chunk (GLB_EMPTY_CHUNK).
551        if let Some(bin_len) = lengths.bin {
552            out.extend_from_slice(&bin_len.to_le_bytes());
553            out.extend_from_slice(b"BIN\0");
554            out.extend_from_slice(&bin);
555        }
556        std::fs::write(path, out).map_err(io_err)?;
557    } else {
558        let text = serde_json::to_string_pretty(&root)?;
559        std::fs::write(path, text).map_err(io_err)?;
560    }
561    Ok(summary)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::{GlbLengths, glb_len_u32, plan_glb_lengths};
567    use crate::WriteError;
568
569    #[test]
570    fn glb_len_u32_accepts_up_to_the_u32_limit() {
571        assert_eq!(glb_len_u32("x", 0).unwrap(), 0);
572        assert_eq!(glb_len_u32("x", 1234).unwrap(), 1234);
573        assert_eq!(glb_len_u32("x", u32::MAX as usize).unwrap(), u32::MAX);
574    }
575
576    // A length past the u32 limit is only representable where usize is
577    // wider than u32; on a 32-bit target the value can't be constructed.
578    #[test]
579    #[cfg(target_pointer_width = "64")]
580    fn glb_len_u32_rejects_over_4gib() {
581        let too_big = u32::MAX as usize + 1;
582        let err = glb_len_u32("total GLB length", too_big).unwrap_err();
583        let msg = err.to_string();
584        assert!(
585            matches!(err, WriteError::TooLarge { field: "total GLB length", bytes } if bytes == too_big),
586            "expected TooLarge naming the field and size"
587        );
588        assert!(
589            msg.contains("4 GiB") && msg.contains("total GLB length"),
590            "message must name the limit and field: {msg}"
591        );
592    }
593
594    // The seam `write()` actually uses: from JSON/BIN payload lengths it
595    // derives the three u32 fields. An 8-byte JSON + 16-byte BIN gives a
596    // total of 12 (header) + 8+8 (JSON chunk) + 8+16 (BIN chunk) = 52.
597    #[test]
598    fn plan_glb_lengths_derives_the_three_fields() {
599        let GlbLengths { total, json, bin } = plan_glb_lengths(8, 16).unwrap();
600        assert_eq!((total, json, bin), (12 + 8 + 8 + 8 + 16, 8, Some(16)));
601        // Empty BIN payload → no BIN chunk, total drops the 8+bin bytes.
602        let GlbLengths { total, json, bin } = plan_glb_lengths(8, 0).unwrap();
603        assert_eq!((total, json, bin), (12 + 8 + 8, 8, None));
604    }
605
606    // Pins the writer's length-field wiring without allocating a >4 GiB
607    // document: each oversized field is attributed to *itself*, not
608    // masked as a total overflow. (Regression guard for the wiring — a
609    // `write()` that skipped `plan_glb_lengths` would drop this coverage.)
610    #[test]
611    #[cfg(target_pointer_width = "64")]
612    fn plan_glb_lengths_attributes_each_overflowing_field() {
613        let over = u32::MAX as usize + 1;
614        let ok = 8usize;
615        let field = |r: Result<GlbLengths, WriteError>| match r.unwrap_err() {
616            WriteError::TooLarge { field, .. } => field,
617            other => panic!("expected TooLarge, got {other:?}"),
618        };
619        assert_eq!(field(plan_glb_lengths(over, ok)), "JSON chunk");
620        assert_eq!(field(plan_glb_lengths(ok, over)), "BIN chunk");
621        // Both parts fit in u32 but their sum overflows the total.
622        let half = u32::MAX as usize / 2 + 1;
623        assert_eq!(field(plan_glb_lengths(half, half)), "total GLB length");
624    }
625}