use mocari::{
assets::{load_model, load_model_runtime},
json::Motion3,
motion::MotionPlayer,
};
#[test]
fn runtime_default_pose_matches_default_model() {
let runtime = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let default = load_model("assets/models/Haru/Haru.model3.json").unwrap();
let runtime_meshes = runtime.runtime().meshes();
let default_meshes = default.meshes();
assert_eq!(runtime_meshes.len(), default_meshes.len());
for (left, right) in runtime_meshes.iter().zip(default_meshes) {
assert_eq!(left.vertices(), right.vertices());
}
}
#[test]
fn setting_a_parameter_changes_mesh_vertices() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let before: Vec<_> = model
.runtime()
.meshes()
.iter()
.map(|mesh| mesh.vertices().to_vec())
.collect();
let angle_x = model
.runtime()
.parameter_ids()
.iter()
.find(|id| id.as_str() == "ParamAngleX")
.cloned()
.expect("Haru has ParamAngleX");
model.runtime_mut().set_parameter(&angle_x, 30.0);
model.runtime_mut().update_meshes().unwrap();
let after: Vec<_> = model
.runtime()
.meshes()
.iter()
.map(|mesh| mesh.vertices().to_vec())
.collect();
assert_ne!(before, after, "moving ParamAngleX should deform the mesh");
}
#[test]
fn set_parameter_clamps_to_model_range() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let id = "ParamAngleX";
let index = model.runtime().parameter_index(id).unwrap();
model.runtime_mut().set_parameter(id, 1_000_000.0);
let high = model.runtime().parameter_value_by_index(index).unwrap();
model.runtime_mut().set_parameter(id, -1_000_000.0);
let low = model.runtime().parameter_value_by_index(index).unwrap();
assert!(high < 1_000_000.0, "value must be clamped to the maximum");
assert!(low > -1_000_000.0, "value must be clamped to the minimum");
assert!(low < high);
}
#[test]
fn motion_player_drives_a_parameter_over_time() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let motion =
mocari::motion::load_motion("assets/models/Haru/motions/haru_g_idle.motion3.json").unwrap();
let target = motion
.curves()
.iter()
.find(|curve| curve.target() == "Parameter")
.map(|curve| curve.id().to_owned())
.expect("idle motion has a parameter curve");
let index = model
.runtime()
.parameter_index(&target)
.expect("motion parameter exists on model");
let mut player = MotionPlayer::new(motion);
player.tick(0.5);
player.apply(model.runtime_mut());
let first = model.runtime().parameter_value_by_index(index).unwrap();
player.tick(0.5);
player.apply(model.runtime_mut());
let second = model.runtime().parameter_value_by_index(index).unwrap();
assert!(first.is_finite() && second.is_finite());
}
#[test]
fn non_looping_motion_finishes_after_duration() {
let motion = Motion3::from_json_str(
r#"{
"Version": 3,
"Meta": { "Duration": 1.0, "Fps": 30.0, "Loop": false },
"Curves": [
{ "Target": "Parameter", "Id": "ParamAngleX", "Segments": [0.0, 0.0, 0, 1.0, 10.0] }
]
}"#,
)
.unwrap();
let mut player = MotionPlayer::new(motion);
assert!(!player.is_finished());
player.tick(0.5);
assert!(!player.is_finished());
player.tick(0.6);
assert!(player.is_finished());
assert_eq!(player.time(), 1.0);
}
#[test]
fn looping_motion_wraps_time() {
let motion = Motion3::from_json_str(
r#"{
"Version": 3,
"Meta": { "Duration": 1.0, "Fps": 30.0, "Loop": true },
"Curves": [
{ "Target": "Parameter", "Id": "ParamAngleX", "Segments": [0.0, 0.0, 0, 1.0, 10.0] }
]
}"#,
)
.unwrap();
let mut player = MotionPlayer::new(motion);
player.tick(1.5);
assert!(!player.is_finished());
assert!((player.time() - 0.5).abs() < 0.0001);
}
fn hiyori_mesh_snapshot(model: &mocari::assets::RuntimeModel) -> Vec<Vec<[f32; 2]>> {
model
.runtime()
.meshes()
.iter()
.map(|mesh| mesh.vertices().iter().map(|v| v.position()).collect())
.collect()
}
#[test]
fn hiyori_distinct_bindings_drive_distinct_parameters() {
let mut model = load_model_runtime("assets/models/Hiyori/Hiyori.model3.json").unwrap();
let baseline = hiyori_mesh_snapshot(&model);
model.runtime_mut().set_parameter("ParamRibbon", 1.0);
model.runtime_mut().update_meshes().unwrap();
let ribbon = hiyori_mesh_snapshot(&model);
assert_ne!(
baseline, ribbon,
"ParamRibbon should deform the Hiyori mesh"
);
model.runtime_mut().reset_parameters();
model.runtime_mut().set_parameter("ParamSkirt2", 1.0);
model.runtime_mut().update_meshes().unwrap();
let skirt = hiyori_mesh_snapshot(&model);
assert_ne!(baseline, skirt, "ParamSkirt2 should deform the Hiyori mesh");
assert_ne!(
ribbon, skirt,
"distinct parameters must drive distinct deformations"
);
}
#[test]
fn zeroing_a_part_hides_its_drawables() {
let mut model = load_model_runtime("assets/models/Hiyori/Hiyori.model3.json").unwrap();
let baseline_visible = model
.runtime()
.meshes()
.iter()
.filter(|mesh| mesh.opacity() > 0.0)
.count();
assert!(baseline_visible > 0);
let part_ids: Vec<String> = model.runtime().part_ids().to_vec();
let mut hid_some = false;
for part_id in part_ids {
model.runtime_mut().reset_part_opacities();
model.runtime_mut().set_part_opacity(&part_id, 0.0);
model.runtime_mut().update_meshes().unwrap();
let visible = model
.runtime()
.meshes()
.iter()
.filter(|mesh| mesh.opacity() > 0.0)
.count();
if visible < baseline_visible {
hid_some = true;
assert!(visible > 0, "a single part should not hide the whole model");
break;
}
}
assert!(hid_some, "no part hid any drawable");
}
#[test]
fn mao_drawables_carry_non_identity_multiply_and_screen_colors() {
let model = load_model_runtime("assets/models/Mao/Mao.model3.json").unwrap();
let non_identity = model
.runtime()
.meshes()
.iter()
.filter(|mesh| {
mesh.multiply_color() != [1.0, 1.0, 1.0] || mesh.screen_color() != [0.0, 0.0, 0.0]
})
.count();
assert!(
non_identity > 0,
"Mao should expose per-drawable color keyforms"
);
}
#[test]
fn legacy_model_without_color_keyforms_defaults_to_identity() {
let model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
for mesh in model.runtime().meshes() {
assert_eq!(mesh.multiply_color(), [1.0, 1.0, 1.0]);
assert_eq!(mesh.screen_color(), [0.0, 0.0, 0.0]);
}
}
#[test]
fn deformer_opacity_hides_rest_state_effects() {
let model = load_model_runtime("assets/models/Rice/Rice.model3.json").unwrap();
let meshes = model.runtime().meshes();
for &index in &[151usize, 152, 153] {
assert_eq!(
meshes[index].opacity(),
0.0,
"magic-circle drawable {index} must be hidden by deformer opacity at rest"
);
}
}
#[test]
fn default_pose_hides_redundant_arm_via_pose_groups() {
let model = load_model_runtime("assets/models/Hiyori/Hiyori.model3.json").unwrap();
let hidden = model
.runtime()
.meshes()
.iter()
.filter(|mesh| mesh.opacity() == 0.0)
.count();
let visible = model
.runtime()
.meshes()
.iter()
.filter(|mesh| mesh.opacity() > 0.0)
.count();
assert!(
hidden > 0,
"pose group should hide the redundant arm at rest"
);
assert!(visible > 0, "the selected arm and body must stay visible");
}