mmd-anim-cli 0.3.0

CLI diagnostics and roundtrip tools for mmd-anim
use std::{collections::BTreeMap, fs, path::Path, process::ExitCode};

pub(crate) fn rig_inspect(
    pmx_path: &Path,
    use_json: bool,
    show_bones: bool,
) -> Result<ExitCode, Box<dyn std::error::Error>> {
    let data =
        fs::read(pmx_path).map_err(|e| format!("failed to read {}: {}", pmx_path.display(), e))?;
    let parsed = mmd_anim_format::parse_pmx_model(&data).map_err(|e| {
        format!(
            "rig-inspect requires a PMX model file: {}: {}",
            pmx_path.display(),
            e
        )
    })?;
    let bones = &parsed.skeleton.bones;
    let bone_count = bones.len();

    let ik_bones: Vec<(usize, &mmd_anim_format::pmx::PmxParsedBone)> = bones
        .iter()
        .enumerate()
        .filter(|(_, b)| b.ik.is_some())
        .collect();

    let grant_bones: Vec<(usize, &mmd_anim_format::pmx::PmxParsedBone)> = bones
        .iter()
        .enumerate()
        .filter(|(_, b)| b.append_transform.is_some())
        .collect();

    let mut layer_distribution = BTreeMap::<i32, usize>::new();
    for bone in bones {
        *layer_distribution.entry(bone.layer).or_default() += 1;
    }

    if use_json {
        print_json(
            &parsed,
            bones,
            &ik_bones,
            &grant_bones,
            &layer_distribution,
            show_bones,
        )?;
    } else {
        print_human(
            &parsed,
            bones,
            bone_count,
            &ik_bones,
            &grant_bones,
            &layer_distribution,
            show_bones,
        );
    }

    Ok(ExitCode::SUCCESS)
}

fn print_json(
    parsed: &mmd_anim_format::pmx::PmxParsedModel,
    bones: &[mmd_anim_format::pmx::PmxParsedBone],
    ik_bones: &[(usize, &mmd_anim_format::pmx::PmxParsedBone)],
    grant_bones: &[(usize, &mmd_anim_format::pmx::PmxParsedBone)],
    layer_distribution: &BTreeMap<i32, usize>,
    show_bones: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let ik_chains: Vec<serde_json::Value> = ik_bones
        .iter()
        .map(|(idx, bone)| {
            let ik = bone.ik.as_ref().unwrap();
            let links: Vec<serde_json::Value> = ik
                .links
                .iter()
                .map(|link| {
                    let link_name = bones
                        .get(link.bone_index as usize)
                        .map(|b| b.name.as_str())
                        .unwrap_or("<unknown>");
                    let mut obj = serde_json::json!({
                        "boneIndex": link.bone_index,
                        "boneName": link_name,
                    });
                    if let Some(limits) = &link.limits {
                        obj["limits"] = serde_json::json!({
                            "lower": limits.lower,
                            "upper": limits.upper,
                        });
                    }
                    obj
                })
                .collect();
            let target_name = bones
                .get(ik.target_index as usize)
                .map(|b| b.name.as_str())
                .unwrap_or("<unknown>");
            serde_json::json!({
                "boneIndex": idx,
                "boneName": bone.name,
                "targetIndex": ik.target_index,
                "targetName": target_name,
                "loopCount": ik.loop_count,
                "limitAngle": ik.limit_angle,
                "linkCount": ik.links.len(),
                "links": links,
            })
        })
        .collect();

    let grants: Vec<serde_json::Value> = grant_bones
        .iter()
        .map(|(idx, bone)| {
            let append = bone.append_transform.as_ref().unwrap();
            let source_name = bones
                .get(append.parent_index as usize)
                .map(|b| b.name.as_str())
                .unwrap_or("<unknown>");
            serde_json::json!({
                "boneIndex": idx,
                "boneName": bone.name,
                "sourceIndex": append.parent_index,
                "sourceName": source_name,
                "weight": append.weight,
                "affectRotation": bone.flags.append_rotate,
                "affectTranslation": bone.flags.append_translate,
                "local": bone.flags.append_local,
            })
        })
        .collect();

    let layers: serde_json::Value = layer_distribution
        .iter()
        .map(|(layer, count)| (layer.to_string(), serde_json::json!(count)))
        .collect::<serde_json::Map<String, serde_json::Value>>()
        .into();

    let mut root = serde_json::json!({
        "modelName": parsed.metadata.name,
        "boneCount": bones.len(),
        "ikChainCount": ik_bones.len(),
        "grantCount": grant_bones.len(),
        "ikChains": ik_chains,
        "grants": grants,
        "deformLayers": layers,
    });

    if show_bones {
        let bone_list: Vec<serde_json::Value> = bones
            .iter()
            .enumerate()
            .map(|(idx, bone)| {
                serde_json::json!({
                    "index": idx,
                    "name": bone.name,
                    "englishName": bone.english_name,
                    "parentIndex": bone.parent_index,
                    "layer": bone.layer,
                    "position": bone.position,
                    "flags": {
                        "rotatable": bone.flags.rotatable,
                        "translatable": bone.flags.translatable,
                        "visible": bone.flags.visible,
                        "enabled": bone.flags.enabled,
                        "ik": bone.flags.ik,
                        "appendRotate": bone.flags.append_rotate,
                        "appendTranslate": bone.flags.append_translate,
                        "fixedAxis": bone.flags.fixed_axis,
                        "localAxis": bone.flags.local_axis,
                        "transformAfterPhysics": bone.flags.transform_after_physics,
                    },
                })
            })
            .collect();
        root["bones"] = serde_json::json!(bone_list);
    }

    println!("{}", serde_json::to_string_pretty(&root)?);
    Ok(())
}

fn print_human(
    parsed: &mmd_anim_format::pmx::PmxParsedModel,
    bones: &[mmd_anim_format::pmx::PmxParsedBone],
    bone_count: usize,
    ik_bones: &[(usize, &mmd_anim_format::pmx::PmxParsedBone)],
    grant_bones: &[(usize, &mmd_anim_format::pmx::PmxParsedBone)],
    layer_distribution: &BTreeMap<i32, usize>,
    show_bones: bool,
) {
    println!(
        "rig-inspect: model={} bones={} ikChains={} grantCount={}",
        parsed.metadata.name,
        bone_count,
        ik_bones.len(),
        grant_bones.len(),
    );

    let layer_str = layer_distribution
        .iter()
        .map(|(layer, count)| format!("{layer}:{count}"))
        .collect::<Vec<_>>()
        .join(",");
    println!("deform layers: {layer_str}");

    if !ik_bones.is_empty() {
        println!("\nIK chains ({}):", ik_bones.len());
        for (idx, bone) in ik_bones {
            let ik = bone.ik.as_ref().unwrap();
            let target_name = bones
                .get(ik.target_index as usize)
                .map(|b| b.name.as_str())
                .unwrap_or("<unknown>");
            println!(
                "  [{idx}] {} -> target={} links={} loopCount={} limitAngle={}",
                bone.name,
                target_name,
                ik.links.len(),
                ik.loop_count,
                ik.limit_angle,
            );
        }
    }

    if !grant_bones.is_empty() {
        println!("\ngrants ({}):", grant_bones.len());
        for (idx, bone) in grant_bones {
            let append = bone.append_transform.as_ref().unwrap();
            let source_name = bones
                .get(append.parent_index as usize)
                .map(|b| b.name.as_str())
                .unwrap_or("<unknown>");
            let mut affects = Vec::new();
            if bone.flags.append_rotate {
                affects.push("rotation");
            }
            if bone.flags.append_translate {
                affects.push("translation");
            }
            let affects_str = if affects.is_empty() {
                "none".to_owned()
            } else {
                affects.join("+")
            };
            println!(
                "  [{idx}] {} <- source={} weight={:.4} affects={}{}",
                bone.name,
                source_name,
                append.weight,
                affects_str,
                if bone.flags.append_local {
                    " (local)"
                } else {
                    ""
                },
            );
        }
    }

    if show_bones {
        println!("\nbones ({bone_count}):");
        for (idx, bone) in bones.iter().enumerate() {
            let parent_str = if bone.parent_index < 0 {
                "-1".to_owned()
            } else {
                format!("{}", bone.parent_index)
            };
            let mut flags = Vec::new();
            if bone.flags.ik {
                flags.push("IK");
            }
            if bone.flags.append_rotate || bone.flags.append_translate {
                flags.push("grant");
            }
            if bone.flags.fixed_axis {
                flags.push("fixedAxis");
            }
            if bone.flags.transform_after_physics {
                flags.push("afterPhysics");
            }
            if !bone.flags.visible {
                flags.push("hidden");
            }
            let flags_str = if flags.is_empty() {
                String::new()
            } else {
                format!(" [{}]", flags.join(","))
            };
            println!(
                "  [{idx}] {} parent={} layer={} pos=({:.4},{:.4},{:.4}){}",
                bone.name,
                parent_str,
                bone.layer,
                bone.position[0],
                bone.position[1],
                bone.position[2],
                flags_str,
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rig_inspect_returns_error_for_missing_file() {
        let result = rig_inspect(Path::new("nonexistent.pmx"), false, false);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("nonexistent.pmx"),
            "error should contain path: {err}"
        );
    }
}