use crate::{FixError, LoadError, WriteError, resolve_buffers, safe_external_buffer_path};
use std::ops::Range;
use std::path::Path;
const ROTATION_ELEMENT_BYTES: usize = 16;
const QUAT_NORM_TOLERANCE: f32 = 1e-3;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TrackFix {
pub clip: String,
pub bone: String,
pub fixed_keys: usize,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct FixReport {
pub tracks: Vec<TrackFix>,
pub skipped: Vec<String>,
}
impl FixReport {
pub fn total_fixed(&self) -> usize {
self.tracks.iter().map(|t| t.fixed_keys).sum()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Repair {
QuatNorm,
QuatFlip,
}
impl Repair {
pub const ALL: &'static [Self] = &[Self::QuatNorm, Self::QuatFlip];
pub fn id(self) -> &'static str {
match self {
Self::QuatNorm => "quat-norm",
Self::QuatFlip => "quat-flip",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|repair| repair.id() == id)
}
}
pub struct FixSession {
original: Vec<u8>,
gltf: gltf::Gltf,
buffers: Vec<Vec<u8>>,
}
impl FixSession {
pub fn read(input: &Path) -> Result<Self, FixError> {
let original = std::fs::read(input).map_err(|source| LoadError::Io {
path: input.display().to_string(),
source,
})?;
crate::validate_glb_framing(&original)?;
let gltf = gltf::Gltf::from_slice(&original).map_err(LoadError::from)?;
crate::validate_animation_channels(gltf.document.as_json())?;
let buffers = resolve_buffers(&gltf, input.parent())?;
Ok(Self {
original,
gltf,
buffers,
})
}
pub fn apply(&mut self, repair: Repair) -> FixReport {
match repair {
Repair::QuatNorm => self.apply_quat_norm(),
Repair::QuatFlip => self.apply_quat_flip(),
}
}
pub fn apply_to_path(
input: &Path,
output: &Path,
repair: Repair,
) -> Result<FixReport, FixError> {
let mut session = Self::read(input)?;
let report = session.apply(repair);
session.write(input, output)?;
Ok(report)
}
pub fn inspect(input: &Path, repair: Repair) -> Result<FixReport, FixError> {
let mut session = Self::read(input)?;
Ok(session.apply(repair))
}
fn apply_quat_norm(&mut self) -> FixReport {
self.repair_rotation_tracks(|buffer, layout| {
if layout.cubic {
let needs_repair = (0..layout.keys).any(|k| {
let value_element = k * layout.per_key + layout.value_offset;
let q = read_rotation(buffer, layout, value_element);
if !q.iter().all(|v| v.is_finite()) {
return false;
}
let len = q.iter().map(|v| v * v).sum::<f32>().sqrt();
len <= f32::EPSILON || (len - 1.0).abs() > QUAT_NORM_TOLERANCE
});
return (
0,
needs_repair.then_some(
"cubic rotation output (quat-norm skipped to preserve tangents)",
),
);
}
let mut fixed = 0usize;
let mut skipped = None;
for k in 0..layout.keys {
let value_element = k * layout.per_key + layout.value_offset;
let q = read_rotation(buffer, layout, value_element);
if !q.iter().all(|v| v.is_finite()) {
skipped = Some("non-finite rotation key");
continue;
}
let len = q.iter().map(|v| v * v).sum::<f32>().sqrt();
if len <= f32::EPSILON {
skipped = Some("zero-length rotation key");
continue;
}
if (len - 1.0).abs() > QUAT_NORM_TOLERANCE {
write_rotation(buffer, layout, value_element, q.map(|v| v / len));
fixed += 1;
}
}
(fixed, skipped)
})
}
fn apply_quat_flip(&mut self) -> FixReport {
self.repair_rotation_tracks(|buffer, layout| {
let mut prev: Option<[f32; 4]> = None;
let mut flipped = 0usize;
for k in 0..layout.keys {
let value_element = k * layout.per_key + layout.value_offset;
let q = read_rotation(buffer, layout, value_element);
if let Some(p) = prev {
let dot: f32 = p.iter().zip(&q).map(|(a, b)| a * b).sum();
if dot < 0.0 {
for e in (k * layout.per_key)..(k * layout.per_key + layout.per_key) {
let negated = read_rotation(buffer, layout, e).map(|v| -v);
write_rotation(buffer, layout, e, negated);
}
flipped += 1;
prev = Some(q.map(|v| -v));
continue;
}
}
prev = Some(q);
}
(flipped, None)
})
}
fn repair_rotation_tracks(
&mut self,
mut repair: impl FnMut(&mut [u8], RotationLayout) -> (usize, Option<&'static str>),
) -> FixReport {
let mut report = FixReport::default();
let gltf = &self.gltf;
let buffers = &mut self.buffers;
for animation in gltf.animations() {
let clip = animation.name().unwrap_or("<unnamed>").to_string();
for channel in animation.channels() {
if channel.target().property() != gltf::animation::Property::Rotation {
continue;
}
let bone = channel
.target()
.node()
.name()
.unwrap_or("<unnamed>")
.to_string();
let sampler = channel.sampler();
let cubic = sampler.interpolation() == gltf::animation::Interpolation::CubicSpline;
let accessor = sampler.output();
if accessor.sparse().is_some() {
report
.skipped
.push(format!("{clip}/{bone}: sparse accessor"));
continue;
}
if accessor.data_type() != gltf::accessor::DataType::F32
|| accessor.dimensions() != gltf::accessor::Dimensions::Vec4
{
report.skipped.push(format!(
"{clip}/{bone}: quantized rotation output ({:?})",
accessor.data_type()
));
continue;
}
let Some(view) = accessor.view() else {
report
.skipped
.push(format!("{clip}/{bone}: accessor without view"));
continue;
};
let buffer_index = view.buffer().index();
if matches!(
view.buffer().source(),
gltf::buffer::Source::Uri(uri) if uri.starts_with("data:")
) {
report.skipped.push(format!(
"{clip}/{bone}: data-URI buffer (convert to .glb first)"
));
continue;
}
let stride = view.stride().unwrap_or(16);
let Some(start) = view.offset().checked_add(accessor.offset()) else {
report
.skipped
.push(format!("{clip}/{bone}: accessor byte offset overflow"));
continue;
};
let Some(buffer) = buffers.get_mut(buffer_index) else {
report
.skipped
.push(format!("{clip}/{bone}: missing buffer {buffer_index}"));
continue;
};
let (per_key, value_offset) = if cubic { (3usize, 1usize) } else { (1, 0) };
if accessor.count() % per_key != 0 {
report
.skipped
.push(format!("{clip}/{bone}: malformed cubic rotation accessor"));
continue;
}
let Some(range) =
accessor_byte_range(start, stride, accessor.count(), ROTATION_ELEMENT_BYTES)
else {
report
.skipped
.push(format!("{clip}/{bone}: accessor byte range overflow"));
continue;
};
if range.end > buffer.len() {
report.skipped.push(format!(
"{clip}/{bone}: accessor byte range {}..{} outside buffer length {}",
range.start,
range.end,
buffer.len()
));
continue;
}
let layout = RotationLayout {
start,
stride,
keys: accessor.count() / per_key,
per_key,
value_offset,
cubic,
};
let (fixed_keys, skipped) = repair(buffer, layout);
if let Some(reason) = skipped {
report.skipped.push(format!("{clip}/{bone}: {reason}"));
}
if fixed_keys > 0 {
report.tracks.push(TrackFix {
clip: clip.clone(),
bone,
fixed_keys,
});
}
}
}
report
}
pub fn write(&self, input: &Path, output: &Path) -> Result<(), FixError> {
write_patched(input, output, &self.original, &self.gltf, &self.buffers)
}
}
#[derive(Clone, Copy)]
struct RotationLayout {
start: usize,
stride: usize,
keys: usize,
per_key: usize,
value_offset: usize,
cubic: bool,
}
fn read_rotation(buffer: &[u8], layout: RotationLayout, element: usize) -> [f32; 4] {
let at = layout.start + element * layout.stride;
let mut q = [0f32; 4];
for (c, slot) in q.iter_mut().enumerate() {
let o = at + c * 4;
*slot = f32::from_le_bytes(buffer[o..o + 4].try_into().expect("slice has four bytes"));
}
q
}
fn write_rotation(buffer: &mut [u8], layout: RotationLayout, element: usize, q: [f32; 4]) {
let at = layout.start + element * layout.stride;
for (c, v) in q.iter().enumerate() {
let o = at + c * 4;
buffer[o..o + 4].copy_from_slice(&v.to_le_bytes());
}
}
fn write_patched(
input: &Path,
output: &Path,
original: &[u8],
gltf: &gltf::Gltf,
buffers: &[Vec<u8>],
) -> Result<(), FixError> {
let io_err = |path: &Path| {
let path = path.display().to_string();
move |source: std::io::Error| {
FixError::Write(WriteError::Io {
path: path.clone(),
source,
})
}
};
if original.starts_with(b"glTF") {
let json_len = read_u32_le(original, 12)?;
let bin_chunk_start = 12usize
.checked_add(8)
.and_then(|n| n.checked_add(json_len))
.ok_or_else(|| LoadError::Buffer("malformed GLB chunk length overflow".into()))?;
if bin_chunk_start > original.len() {
return Err(LoadError::Buffer("malformed GLB JSON chunk length".into()).into());
}
let mut out = original[..bin_chunk_start].to_vec();
if bin_chunk_start < original.len() {
let bin_len = read_u32_le(original, bin_chunk_start)?;
let bin_header_end = bin_chunk_start
.checked_add(8)
.ok_or_else(|| LoadError::Buffer("malformed GLB BIN chunk overflow".into()))?;
if bin_header_end > original.len() {
return Err(LoadError::Buffer("malformed GLB BIN chunk header".into()).into());
}
out.extend_from_slice(&original[bin_chunk_start..bin_header_end]);
let bin = gltf
.buffers()
.position(|b| matches!(b.source(), gltf::buffer::Source::Bin))
.and_then(|i| buffers.get(i))
.map(Vec::as_slice)
.unwrap_or(&[]);
if bin.len() > bin_len {
return Err(LoadError::Buffer(format!(
"patched BIN chunk length {} exceeds original length {bin_len}",
bin.len()
))
.into());
}
out.extend_from_slice(bin);
let padding_start = bin_header_end
.checked_add(bin.len())
.ok_or_else(|| LoadError::Buffer("malformed GLB BIN chunk overflow".into()))?;
if padding_start > original.len() {
return Err(LoadError::Buffer("malformed GLB BIN chunk length".into()).into());
}
out.extend_from_slice(&original[padding_start..]);
}
std::fs::write(output, out).map_err(io_err(output))?;
return write_uri_buffers(output, gltf, buffers);
}
if input != output {
std::fs::copy(input, output).map_err(io_err(output))?;
}
write_uri_buffers(output, gltf, buffers)
}
fn write_uri_buffers(
output: &Path,
gltf: &gltf::Gltf,
buffers: &[Vec<u8>],
) -> Result<(), FixError> {
for (buffer, data) in gltf.buffers().zip(buffers) {
if let gltf::buffer::Source::Uri(uri) = buffer.source() {
if uri.starts_with("data:") {
continue; }
let path = output
.parent()
.unwrap_or(Path::new("."))
.join(safe_external_buffer_path(uri)?);
std::fs::write(&path, data).map_err(|source| {
FixError::Write(WriteError::Io {
path: path.display().to_string(),
source,
})
})?;
}
}
Ok(())
}
fn accessor_byte_range(
start: usize,
stride: usize,
element_count: usize,
element_bytes: usize,
) -> Option<Range<usize>> {
if stride < element_bytes {
return None;
}
if element_count == 0 {
return Some(start..start);
}
let last = element_count.checked_sub(1)?;
let last_start = start.checked_add(last.checked_mul(stride)?)?;
Some(start..last_start.checked_add(element_bytes)?)
}
fn read_u32_le(bytes: &[u8], offset: usize) -> Result<usize, LoadError> {
let end = offset
.checked_add(4)
.ok_or_else(|| LoadError::Buffer("malformed GLB offset overflow".into()))?;
let word = bytes
.get(offset..end)
.ok_or_else(|| LoadError::Buffer("malformed GLB chunk header".into()))?;
Ok(u32::from_le_bytes(word.try_into().expect("slice has four bytes")) as usize)
}