Skip to main content

animsmith_gltf/
fix.rs

1//! Byte-surgical clip repairs: mutate only the animation accessor
2//! bytes that need to change and copy everything else through
3//! verbatim. A fixed character GLB keeps its meshes, skins, materials,
4//! and textures byte-identical — the output diff is exactly the
5//! repaired keys.
6//!
7//! Quaternion repairs:
8//! - `quat-norm`: normalize non-unit quaternion keys. Scaling a finite
9//!   non-zero quaternion back to unit length preserves the represented
10//!   rotation and avoids engine-dependent renormalization.
11//! - `quat-flip`: adjacent rotation keys with `dot < 0` make engines
12//!   without neighborhood correction slerp the long way around. Negating
13//!   a quaternion leaves the rotation it represents unchanged, so
14//!   walking each track and negating keys until the whole track is
15//!   hemisphere-consistent is lossless.
16//!
17//! Scope: float32 VEC4 rotation outputs. `quat-flip` handles CUBICSPLINE
18//! tracks by negating the whole `[in-tangent, value, out-tangent]`
19//! triplet with the key — the tangents are derivatives in quaternion
20//! component space, so they flip with it. (Hermite segments between a
21//! flipped and an unflipped key traverse 4-space differently than
22//! authored, but the authored curve was the long-way spin being
23//! repaired.) `quat-norm` skips CUBICSPLINE tracks because scaling value
24//! keys without their tangents would change interior samples. Sparse
25//! accessors and quantized (normalized-int) rotations are skipped.
26
27use crate::{FixError, LoadError, WriteError, resolve_buffers, safe_external_buffer_path};
28use std::ops::Range;
29use std::path::Path;
30
31const ROTATION_ELEMENT_BYTES: usize = 16;
32const QUAT_NORM_TOLERANCE: f32 = 1e-3;
33
34/// One track's repair summary.
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub struct TrackFix {
38    /// Clip that contained the repaired rotation track.
39    pub clip: String,
40    /// Bone/node name targeted by the repaired rotation track.
41    pub bone: String,
42    /// Number of keyed rotations changed by the repair.
43    pub fixed_keys: usize,
44}
45
46/// Summary of one byte-surgical repair pass.
47///
48/// Reports both successful track repairs and tracks that looked relevant
49/// but could not be safely patched without changing the container contract.
50#[derive(Debug, Clone, Default)]
51#[non_exhaustive]
52pub struct FixReport {
53    /// Rotation tracks that were patched.
54    pub tracks: Vec<TrackFix>,
55    /// Tracks that needed repair but were skipped (data URI, cubic,
56    /// quantized, sparse, or malformed accessors), with reasons.
57    pub skipped: Vec<String>,
58}
59
60impl FixReport {
61    /// Total number of keyed rotations changed across all repaired tracks.
62    pub fn total_fixed(&self) -> usize {
63        self.tracks.iter().map(|t| t.fixed_keys).sum()
64    }
65}
66
67/// Byte-surgical repair to run on a [`FixSession`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum Repair {
71    /// Normalize finite, non-zero LINEAR/STEP rotation keys.
72    QuatNorm,
73    /// Hemisphere-normalize adjacent rotation keys.
74    QuatFlip,
75}
76
77impl Repair {
78    /// Every repair supported by this crate.
79    pub const ALL: &'static [Self] = &[Self::QuatNorm, Self::QuatFlip];
80
81    /// Stable repair id, matching the CLI `--repair` value and the
82    /// corresponding check id.
83    pub fn id(self) -> &'static str {
84        match self {
85            Self::QuatNorm => "quat-norm",
86            Self::QuatFlip => "quat-flip",
87        }
88    }
89
90    /// Parse a stable repair id.
91    pub fn from_id(id: &str) -> Option<Self> {
92        Self::ALL.iter().copied().find(|repair| repair.id() == id)
93    }
94}
95
96/// Parsed input plus mutable buffer bytes for one `fix` run.
97///
98/// Use [`FixSession::apply`] to compose repairs in memory before a
99/// single write, or [`FixSession::apply_to_path`] /
100/// [`FixSession::inspect`] for one-shot path-based use.
101pub struct FixSession {
102    original: Vec<u8>,
103    gltf: gltf::Gltf,
104    buffers: Vec<Vec<u8>>,
105}
106
107impl FixSession {
108    /// Read and parse a glTF/GLB once, loading every declared buffer.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`FixError::Load`] when the input cannot be read, parsed,
113    /// framed as a safe GLB, or resolved through its external buffers.
114    pub fn read(input: &Path) -> Result<Self, FixError> {
115        let original = std::fs::read(input).map_err(|source| LoadError::Io {
116            path: input.display().to_string(),
117            source,
118        })?;
119        crate::validate_glb_framing(&original)?;
120        let gltf = gltf::Gltf::from_slice(&original).map_err(LoadError::from)?;
121        crate::validate_animation_channels(gltf.document.as_json())?;
122
123        // Buffers as mutable byte vectors, indexed as the JSON declares
124        // them (the BIN-chunk buffer is located by Source::Bin at write
125        // time, not assumed to be index 0). External and data-URI buffers
126        // are loaded, patched, and written back to where they came from.
127        let buffers = resolve_buffers(&gltf, input.parent())?;
128
129        Ok(Self {
130            original,
131            gltf,
132            buffers,
133        })
134    }
135
136    /// Apply one repair in memory.
137    ///
138    /// This does not write any bytes. Call [`FixSession::write`] after one
139    /// or more repair passes to persist the patched buffers.
140    pub fn apply(&mut self, repair: Repair) -> FixReport {
141        match repair {
142            Repair::QuatNorm => self.apply_quat_norm(),
143            Repair::QuatFlip => self.apply_quat_flip(),
144        }
145    }
146
147    /// Apply one repair to `input`, writing the (otherwise
148    /// byte-identical) result to `output`. `input` and `output` may be
149    /// the same path.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`FixError::Load`] for unreadable or malformed input and
154    /// [`FixError::Write`] when the patched container cannot be written.
155    pub fn apply_to_path(
156        input: &Path,
157        output: &Path,
158        repair: Repair,
159    ) -> Result<FixReport, FixError> {
160        let mut session = Self::read(input)?;
161        let report = session.apply(repair);
162        session.write(input, output)?;
163        Ok(report)
164    }
165
166    /// Inspect which tracks one repair would update without writing any
167    /// bytes.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`FixError::Load`] when the input cannot be read or parsed.
172    pub fn inspect(input: &Path, repair: Repair) -> Result<FixReport, FixError> {
173        let mut session = Self::read(input)?;
174        Ok(session.apply(repair))
175    }
176
177    fn apply_quat_norm(&mut self) -> FixReport {
178        self.repair_rotation_tracks(|buffer, layout| {
179            if layout.cubic {
180                let needs_repair = (0..layout.keys).any(|k| {
181                    let value_element = k * layout.per_key + layout.value_offset;
182                    let q = read_rotation(buffer, layout, value_element);
183                    if !q.iter().all(|v| v.is_finite()) {
184                        return false;
185                    }
186                    let len = q.iter().map(|v| v * v).sum::<f32>().sqrt();
187                    len <= f32::EPSILON || (len - 1.0).abs() > QUAT_NORM_TOLERANCE
188                });
189                return (
190                    0,
191                    needs_repair.then_some(
192                        "cubic rotation output (quat-norm skipped to preserve tangents)",
193                    ),
194                );
195            }
196
197            let mut fixed = 0usize;
198            let mut skipped = None;
199            for k in 0..layout.keys {
200                let value_element = k * layout.per_key + layout.value_offset;
201                let q = read_rotation(buffer, layout, value_element);
202                if !q.iter().all(|v| v.is_finite()) {
203                    skipped = Some("non-finite rotation key");
204                    continue;
205                }
206                let len = q.iter().map(|v| v * v).sum::<f32>().sqrt();
207                if len <= f32::EPSILON {
208                    skipped = Some("zero-length rotation key");
209                    continue;
210                }
211                if (len - 1.0).abs() > QUAT_NORM_TOLERANCE {
212                    write_rotation(buffer, layout, value_element, q.map(|v| v / len));
213                    fixed += 1;
214                }
215            }
216            (fixed, skipped)
217        })
218    }
219
220    fn apply_quat_flip(&mut self) -> FixReport {
221        self.repair_rotation_tracks(|buffer, layout| {
222            let mut prev: Option<[f32; 4]> = None;
223            let mut flipped = 0usize;
224            for k in 0..layout.keys {
225                let value_element = k * layout.per_key + layout.value_offset;
226                let q = read_rotation(buffer, layout, value_element);
227                if let Some(p) = prev {
228                    let dot: f32 = p.iter().zip(&q).map(|(a, b)| a * b).sum();
229                    if dot < 0.0 {
230                        for e in (k * layout.per_key)..(k * layout.per_key + layout.per_key) {
231                            let negated = read_rotation(buffer, layout, e).map(|v| -v);
232                            write_rotation(buffer, layout, e, negated);
233                        }
234                        flipped += 1;
235                        prev = Some(q.map(|v| -v));
236                        continue;
237                    }
238                }
239                prev = Some(q);
240            }
241            (flipped, None)
242        })
243    }
244
245    fn repair_rotation_tracks(
246        &mut self,
247        mut repair: impl FnMut(&mut [u8], RotationLayout) -> (usize, Option<&'static str>),
248    ) -> FixReport {
249        let mut report = FixReport::default();
250        let gltf = &self.gltf;
251        let buffers = &mut self.buffers;
252        for animation in gltf.animations() {
253            let clip = animation.name().unwrap_or("<unnamed>").to_string();
254            for channel in animation.channels() {
255                if channel.target().property() != gltf::animation::Property::Rotation {
256                    continue;
257                }
258                let bone = channel
259                    .target()
260                    .node()
261                    .name()
262                    .unwrap_or("<unnamed>")
263                    .to_string();
264                let sampler = channel.sampler();
265                let cubic = sampler.interpolation() == gltf::animation::Interpolation::CubicSpline;
266                let accessor = sampler.output();
267                if accessor.sparse().is_some() {
268                    report
269                        .skipped
270                        .push(format!("{clip}/{bone}: sparse accessor"));
271                    continue;
272                }
273                if accessor.data_type() != gltf::accessor::DataType::F32
274                    || accessor.dimensions() != gltf::accessor::Dimensions::Vec4
275                {
276                    report.skipped.push(format!(
277                        "{clip}/{bone}: quantized rotation output ({:?})",
278                        accessor.data_type()
279                    ));
280                    continue;
281                }
282                let Some(view) = accessor.view() else {
283                    report
284                        .skipped
285                        .push(format!("{clip}/{bone}: accessor without view"));
286                    continue;
287                };
288                let buffer_index = view.buffer().index();
289                if matches!(
290                    view.buffer().source(),
291                    gltf::buffer::Source::Uri(uri) if uri.starts_with("data:")
292                ) {
293                    report.skipped.push(format!(
294                        "{clip}/{bone}: data-URI buffer (convert to .glb first)"
295                    ));
296                    continue;
297                }
298                let stride = view.stride().unwrap_or(16);
299                let Some(start) = view.offset().checked_add(accessor.offset()) else {
300                    report
301                        .skipped
302                        .push(format!("{clip}/{bone}: accessor byte offset overflow"));
303                    continue;
304                };
305                let Some(buffer) = buffers.get_mut(buffer_index) else {
306                    report
307                        .skipped
308                        .push(format!("{clip}/{bone}: missing buffer {buffer_index}"));
309                    continue;
310                };
311
312                // Cubic outputs hold [in-tangent, value, out-tangent]
313                // triplets; repairs inspect VALUE elements, while
314                // hemisphere flips negate whole triplets.
315                let (per_key, value_offset) = if cubic { (3usize, 1usize) } else { (1, 0) };
316                if accessor.count() % per_key != 0 {
317                    report
318                        .skipped
319                        .push(format!("{clip}/{bone}: malformed cubic rotation accessor"));
320                    continue;
321                }
322                let Some(range) =
323                    accessor_byte_range(start, stride, accessor.count(), ROTATION_ELEMENT_BYTES)
324                else {
325                    report
326                        .skipped
327                        .push(format!("{clip}/{bone}: accessor byte range overflow"));
328                    continue;
329                };
330                if range.end > buffer.len() {
331                    report.skipped.push(format!(
332                        "{clip}/{bone}: accessor byte range {}..{} outside buffer length {}",
333                        range.start,
334                        range.end,
335                        buffer.len()
336                    ));
337                    continue;
338                }
339                let layout = RotationLayout {
340                    start,
341                    stride,
342                    keys: accessor.count() / per_key,
343                    per_key,
344                    value_offset,
345                    cubic,
346                };
347                let (fixed_keys, skipped) = repair(buffer, layout);
348                if let Some(reason) = skipped {
349                    report.skipped.push(format!("{clip}/{bone}: {reason}"));
350                }
351                if fixed_keys > 0 {
352                    report.tracks.push(TrackFix {
353                        clip: clip.clone(),
354                        bone,
355                        fixed_keys,
356                    });
357                }
358            }
359        }
360        report
361    }
362
363    /// Write the patched buffers, preserving the original container bytes.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`FixError::Load`] if the original container framing cannot
368    /// be safely reassembled, or [`FixError::Write`] if writing the output
369    /// path fails.
370    pub fn write(&self, input: &Path, output: &Path) -> Result<(), FixError> {
371        write_patched(input, output, &self.original, &self.gltf, &self.buffers)
372    }
373}
374
375#[derive(Clone, Copy)]
376struct RotationLayout {
377    start: usize,
378    stride: usize,
379    keys: usize,
380    per_key: usize,
381    value_offset: usize,
382    cubic: bool,
383}
384
385fn read_rotation(buffer: &[u8], layout: RotationLayout, element: usize) -> [f32; 4] {
386    let at = layout.start + element * layout.stride;
387    let mut q = [0f32; 4];
388    for (c, slot) in q.iter_mut().enumerate() {
389        let o = at + c * 4;
390        *slot = f32::from_le_bytes(buffer[o..o + 4].try_into().expect("slice has four bytes"));
391    }
392    q
393}
394
395fn write_rotation(buffer: &mut [u8], layout: RotationLayout, element: usize, q: [f32; 4]) {
396    let at = layout.start + element * layout.stride;
397    for (c, v) in q.iter().enumerate() {
398        let o = at + c * 4;
399        buffer[o..o + 4].copy_from_slice(&v.to_le_bytes());
400    }
401}
402
403/// Reassemble the container with the original structure and the
404/// patched buffer bytes.
405fn write_patched(
406    input: &Path,
407    output: &Path,
408    original: &[u8],
409    gltf: &gltf::Gltf,
410    buffers: &[Vec<u8>],
411) -> Result<(), FixError> {
412    let io_err = |path: &Path| {
413        let path = path.display().to_string();
414        move |source: std::io::Error| {
415            FixError::Write(WriteError::Io {
416                path: path.clone(),
417                source,
418            })
419        }
420    };
421
422    if original.starts_with(b"glTF") {
423        // GLB: copy the header + JSON chunk verbatim, splice the
424        // patched BIN chunk (same length — we only overwrote values).
425        let json_len = read_u32_le(original, 12)?;
426        let bin_chunk_start = 12usize
427            .checked_add(8)
428            .and_then(|n| n.checked_add(json_len))
429            .ok_or_else(|| LoadError::Buffer("malformed GLB chunk length overflow".into()))?;
430        if bin_chunk_start > original.len() {
431            return Err(LoadError::Buffer("malformed GLB JSON chunk length".into()).into());
432        }
433        let mut out = original[..bin_chunk_start].to_vec();
434        if bin_chunk_start < original.len() {
435            let bin_len = read_u32_le(original, bin_chunk_start)?;
436            let bin_header_end = bin_chunk_start
437                .checked_add(8)
438                .ok_or_else(|| LoadError::Buffer("malformed GLB BIN chunk overflow".into()))?;
439            if bin_header_end > original.len() {
440                return Err(LoadError::Buffer("malformed GLB BIN chunk header".into()).into());
441            }
442            out.extend_from_slice(&original[bin_chunk_start..bin_header_end]);
443            // The BIN chunk holds the buffer with Source::Bin (buffer 0
444            // per spec when present) — not blindly buffers[0], which
445            // may be a URI buffer in a BIN-less GLB.
446            let bin = gltf
447                .buffers()
448                .position(|b| matches!(b.source(), gltf::buffer::Source::Bin))
449                .and_then(|i| buffers.get(i))
450                .map(Vec::as_slice)
451                .unwrap_or(&[]);
452            if bin.len() > bin_len {
453                return Err(LoadError::Buffer(format!(
454                    "patched BIN chunk length {} exceeds original length {bin_len}",
455                    bin.len()
456                ))
457                .into());
458            }
459            out.extend_from_slice(bin);
460            // Preserve the original chunk padding.
461            let padding_start = bin_header_end
462                .checked_add(bin.len())
463                .ok_or_else(|| LoadError::Buffer("malformed GLB BIN chunk overflow".into()))?;
464            if padding_start > original.len() {
465                return Err(LoadError::Buffer("malformed GLB BIN chunk length".into()).into());
466            }
467            out.extend_from_slice(&original[padding_start..]);
468        }
469        std::fs::write(output, out).map_err(io_err(output))?;
470        // A GLB may also reference external URI buffers; their patched
471        // bytes must land on disk too, or "N keys fixed" is a false
472        // success (the repaired keys would be in the untouched .bin).
473        return write_uri_buffers(output, gltf, buffers);
474    }
475
476    // .gltf: the JSON is untouched; copy it through and write each
477    // patched non-data-URI buffer back to its own file (resolved
478    // against the OUTPUT location so `-o elsewhere/` keeps the pair
479    // together).
480    if input != output {
481        std::fs::copy(input, output).map_err(io_err(output))?;
482    }
483    write_uri_buffers(output, gltf, buffers)
484}
485
486/// Write every patched external (non-data-URI) buffer next to
487/// `output`, keeping the container/buffer pair together.
488fn write_uri_buffers(
489    output: &Path,
490    gltf: &gltf::Gltf,
491    buffers: &[Vec<u8>],
492) -> Result<(), FixError> {
493    for (buffer, data) in gltf.buffers().zip(buffers) {
494        if let gltf::buffer::Source::Uri(uri) = buffer.source() {
495            if uri.starts_with("data:") {
496                continue; // never patched — such tracks are skipped upstream
497            }
498            let path = output
499                .parent()
500                .unwrap_or(Path::new("."))
501                .join(safe_external_buffer_path(uri)?);
502            std::fs::write(&path, data).map_err(|source| {
503                FixError::Write(WriteError::Io {
504                    path: path.display().to_string(),
505                    source,
506                })
507            })?;
508        }
509    }
510    Ok(())
511}
512
513fn accessor_byte_range(
514    start: usize,
515    stride: usize,
516    element_count: usize,
517    element_bytes: usize,
518) -> Option<Range<usize>> {
519    if stride < element_bytes {
520        return None;
521    }
522    if element_count == 0 {
523        return Some(start..start);
524    }
525    let last = element_count.checked_sub(1)?;
526    let last_start = start.checked_add(last.checked_mul(stride)?)?;
527    Some(start..last_start.checked_add(element_bytes)?)
528}
529
530fn read_u32_le(bytes: &[u8], offset: usize) -> Result<usize, LoadError> {
531    let end = offset
532        .checked_add(4)
533        .ok_or_else(|| LoadError::Buffer("malformed GLB offset overflow".into()))?;
534    let word = bytes
535        .get(offset..end)
536        .ok_or_else(|| LoadError::Buffer("malformed GLB chunk header".into()))?;
537    Ok(u32::from_le_bytes(word.try_into().expect("slice has four bytes")) as usize)
538}