mmd-anim-format 0.4.1

MMD format parser and exporter for mmd-anim
Documentation
use mmd_anim_runtime::{
    QuantizedBezier, ReducedBoneKey, ReducedPoseSequence, ReductionTarget, VmdBoneInterpolation,
};
use thiserror::Error;

use super::{
    VmdParsedAnimation, VmdParsedBoneFrame, VmdParsedCounts, VmdParsedIkState, VmdParsedMetadata,
    VmdParsedMorphFrame, VmdParsedPropertyFrame,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VmdExportName {
    pub text: String,
    pub bytes: Vec<u8>,
}

impl VmdExportName {
    pub fn new(text: impl Into<String>, bytes: Vec<u8>) -> Self {
        Self {
            text: text.into(),
            bytes,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VmdExportMorphKind {
    Vertex,
    Uv,
    Other,
    Bone,
    Group,
    Material,
}

#[derive(Debug, Clone)]
pub struct VmdPoseExportBindings {
    pub model_identity: u64,
    pub model_name: VmdExportName,
    pub bone_names: Vec<VmdExportName>,
    pub morph_names: Vec<VmdExportName>,
    pub ik_names: Vec<VmdExportName>,
    pub ik_solver_count: usize,
    pub append_affected_bones: Vec<bool>,
    pub morph_kinds: Vec<VmdExportMorphKind>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VmdPoseExportReport {
    pub physics_must_be_disabled_by_host: bool,
    pub ik_disabled_in_vmd: bool,
    pub skipped_constant_bone_tracks: usize,
    pub skipped_zero_morph_tracks: usize,
}

#[derive(Debug, Clone)]
pub struct VmdPoseExport {
    pub animation: VmdParsedAnimation,
    pub report: VmdPoseExportReport,
}

#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum VmdPoseExportError {
    #[error("reduced pose target must be VmdBezier")]
    WrongTarget,
    #[error("export binding model identity or counts do not match reduced pose")]
    BindingMismatch,
    #[error("sample {sample} has non-integer VMD frame {frame}")]
    NonIntegerFrame { sample: usize, frame: String },
    #[error("sample {sample} frame is outside the VMD u32 range")]
    FrameOutOfRange { sample: usize },
    #[error("{kind} name {index} exceeds the VMD byte limit {limit}")]
    NameTooLong {
        kind: &'static str,
        index: usize,
        limit: usize,
    },
    #[error("bone {bone} has baked motion but also participates in append transform")]
    AppendTransformWouldDoubleApply { bone: usize },
    #[error("morph {morph} of kind {kind:?} would double-apply baked deformation")]
    MorphWouldDoubleApply {
        morph: usize,
        kind: VmdExportMorphKind,
    },
    #[error("morph {morph} of kind {kind:?} cannot be emitted by the VMD pose adapter")]
    UnsupportedMorphKind {
        morph: usize,
        kind: VmdExportMorphKind,
    },
}

pub fn export_reduced_pose_to_vmd(
    sequence: &ReducedPoseSequence,
    bindings: &VmdPoseExportBindings,
) -> Result<VmdPoseExport, VmdPoseExportError> {
    if sequence.target() != ReductionTarget::VmdBezier {
        return Err(VmdPoseExportError::WrongTarget);
    }
    let snapshot = sequence.snapshot();
    if !sequence.validate_model(
        bindings.model_identity,
        bindings.bone_names.len(),
        bindings.morph_names.len(),
    ) || bindings.append_affected_bones.len() != snapshot.bone_count()
        || bindings.morph_kinds.len() != snapshot.morph_count()
        || bindings.ik_names.len() != bindings.ik_solver_count
    {
        return Err(VmdPoseExportError::BindingMismatch);
    }
    validate_name(&bindings.model_name, "model", 0, 20)?;
    for (index, name) in bindings.bone_names.iter().enumerate() {
        validate_name(name, "bone", index, 15)?;
    }
    for (index, name) in bindings.morph_names.iter().enumerate() {
        validate_name(name, "morph", index, 15)?;
    }
    for (index, name) in bindings.ik_names.iter().enumerate() {
        validate_name(name, "ik", index, 20)?;
    }

    let sample_frames = vmd_sample_frames(sequence)?;
    let mut bone_frames = Vec::new();
    let mut skipped_constant_bone_tracks = 0;
    for (bone, track) in sequence.bone_tracks().iter().enumerate() {
        let rest_translation = snapshot.rest_local_translations()[bone];
        let rest_rotation = snapshot.rest_local_rotations()[bone];
        let active = track.keys().iter().any(|key| {
            key.translation.distance(rest_translation) > 1.0e-6
                || rotation_error(key.rotation, rest_rotation) > 1.0e-6
        });
        if !active {
            skipped_constant_bone_tracks += 1;
            continue;
        }
        if bindings.append_affected_bones[bone] {
            return Err(VmdPoseExportError::AppendTransformWouldDoubleApply { bone });
        }
        for key in track.keys() {
            bone_frames.push(vmd_bone_frame(
                &bindings.bone_names[bone],
                sample_frames[key.sample_index],
                key,
                rest_translation,
                rest_rotation,
            ));
        }
    }

    let mut morph_frames = Vec::new();
    let mut skipped_zero_morph_tracks = 0;
    for (morph, track) in sequence.morph_tracks().iter().enumerate() {
        let active = track.keys().iter().any(|key| key.weight.abs() > 1.0e-6);
        if !active {
            skipped_zero_morph_tracks += 1;
            continue;
        }
        let kind = bindings.morph_kinds[morph];
        if matches!(
            kind,
            VmdExportMorphKind::Bone | VmdExportMorphKind::Group | VmdExportMorphKind::Material
        ) {
            return Err(VmdPoseExportError::MorphWouldDoubleApply { morph, kind });
        }
        if kind != VmdExportMorphKind::Vertex {
            return Err(VmdPoseExportError::UnsupportedMorphKind { morph, kind });
        }
        for key in track.keys() {
            morph_frames.push(VmdParsedMorphFrame {
                morph_name: bindings.morph_names[morph].text.clone(),
                morph_name_bytes: bindings.morph_names[morph].bytes.clone(),
                frame: sample_frames[key.sample_index],
                weight: key.weight,
            });
        }
    }
    bone_frames.sort_by(|a, b| (a.frame, &a.bone_name_bytes).cmp(&(b.frame, &b.bone_name_bytes)));
    morph_frames
        .sort_by(|a, b| (a.frame, &a.morph_name_bytes).cmp(&(b.frame, &b.morph_name_bytes)));

    let property_frames = vec![VmdParsedPropertyFrame {
        frame: sample_frames[0],
        visible: true,
        ik_states: bindings
            .ik_names
            .iter()
            .map(|name| VmdParsedIkState {
                bone_name: name.text.clone(),
                bone_name_bytes: name.bytes.clone(),
                enabled: false,
            })
            .collect(),
    }];
    let max_frame = *sample_frames.last().expect("validated non-empty sequence");
    let animation = VmdParsedAnimation {
        kind: "vmd",
        metadata: VmdParsedMetadata {
            format: "vmd",
            model_name: bindings.model_name.text.clone(),
            model_name_bytes: bindings.model_name.bytes.clone(),
            counts: VmdParsedCounts {
                bones: bone_frames.len(),
                morphs: morph_frames.len(),
                cameras: 0,
                lights: 0,
                self_shadows: 0,
                properties: property_frames.len(),
            },
            max_frame,
        },
        bone_frames,
        morph_frames,
        camera_frames: Vec::new(),
        light_frames: Vec::new(),
        self_shadow_frames: Vec::new(),
        property_frames,
    };
    Ok(VmdPoseExport {
        animation,
        report: VmdPoseExportReport {
            physics_must_be_disabled_by_host: true,
            ik_disabled_in_vmd: true,
            skipped_constant_bone_tracks,
            skipped_zero_morph_tracks,
        },
    })
}

fn validate_name(
    name: &VmdExportName,
    kind: &'static str,
    index: usize,
    limit: usize,
) -> Result<(), VmdPoseExportError> {
    if name.bytes.len() > limit {
        Err(VmdPoseExportError::NameTooLong { kind, index, limit })
    } else {
        Ok(())
    }
}

fn vmd_sample_frames(sequence: &ReducedPoseSequence) -> Result<Vec<u32>, VmdPoseExportError> {
    (0..sequence.frame_count())
        .map(|sample| {
            let frame = sequence.start_frame() + sample as f32 * sequence.frame_step();
            if frame < 0.0 || frame as f64 > u32::MAX as f64 {
                return Err(VmdPoseExportError::FrameOutOfRange { sample });
            }
            let rounded = frame.round();
            if frame != rounded {
                return Err(VmdPoseExportError::NonIntegerFrame {
                    sample,
                    frame: frame.to_string(),
                });
            }
            Ok(rounded as u32)
        })
        .collect()
}

fn vmd_bone_frame(
    name: &VmdExportName,
    frame: u32,
    key: &ReducedBoneKey,
    rest_translation: glam::Vec3A,
    rest_rotation: glam::Quat,
) -> VmdParsedBoneFrame {
    let translation = key.translation - rest_translation;
    let rotation = (rest_rotation.inverse() * key.rotation).normalize();
    VmdParsedBoneFrame {
        bone_name: name.text.clone(),
        bone_name_bytes: name.bytes.clone(),
        frame,
        translation: translation.to_array(),
        rotation: rotation.to_array(),
        interpolation: vmd_interpolation_block(key.vmd_interpolation).to_vec(),
    }
}

fn vmd_interpolation_block(curves: VmdBoneInterpolation) -> [u8; 64] {
    let channels = [
        curves.translation[0],
        curves.translation[1],
        curves.translation[2],
        curves.rotation,
    ];
    let mut first = [0u8; 16];
    for (channel, curve) in channels.into_iter().enumerate() {
        write_curve(&mut first, channel, curve);
    }
    let mut block = [0u8; 64];
    for chunk in block.chunks_exact_mut(16) {
        chunk.copy_from_slice(&first);
    }
    block
}

fn write_curve(block: &mut [u8; 16], channel: usize, curve: QuantizedBezier) {
    block[channel] = curve.x1.min(127);
    block[4 + channel] = curve.y1.min(127);
    block[8 + channel] = curve.x2.min(127);
    block[12 + channel] = curve.y2.min(127);
}

fn rotation_error(a: glam::Quat, b: glam::Quat) -> f32 {
    let a = a.normalize();
    let mut b = b.normalize();
    if a.dot(b) < 0.0 {
        b = -b;
    }
    4.0 * (((a - b).length() * 0.5).clamp(0.0, 1.0)).asin()
}

#[cfg(test)]
mod tests;