use mocari::{
assets::{load_model, load_model_runtime},
expression::{ExpressionManager, ExpressionPlayer, load_expression},
json::{Expression3, 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 parameter_info_exposes_range_default_and_current_value() {
let model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let index = model.runtime().parameter_index("ParamAngleX").unwrap();
let info = model.runtime().parameter_info_by_index(index).unwrap();
assert_eq!(info.id(), "ParamAngleX");
assert!(info.minimum() < info.maximum());
assert!(info.minimum() <= info.default());
assert!(info.default() <= info.maximum());
assert_eq!(info.default(), info.value());
assert_eq!(
model.runtime().parameter_infos().count(),
model.runtime().parameter_ids().len()
);
}
#[test]
fn set_parameter_normalized_maps_unit_range_to_model_range() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let index = model.runtime().parameter_index("ParamAngleX").unwrap();
let info = model.runtime().parameter_info_by_index(index).unwrap();
let expected = info.minimum() + (info.maximum() - info.minimum()) * 0.75;
assert!(
model
.runtime_mut()
.set_parameter_normalized_by_index(index, 0.75)
);
assert_close(
model.runtime().parameter_value_by_index(index).unwrap(),
expected,
);
assert_close(
model
.runtime()
.parameter_normalized_value_by_index(index)
.unwrap(),
0.75,
);
assert!(
model
.runtime_mut()
.set_parameter_normalized("ParamAngleX", 2.0)
);
assert_close(
model
.runtime()
.parameter_normalized_value_by_index(index)
.unwrap(),
1.0,
);
}
#[test]
fn parameter_overrides_can_be_applied_after_parameter_reset() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let index = model.runtime().parameter_index("ParamAngleX").unwrap();
let info = model.runtime().parameter_info_by_index(index).unwrap();
let maximum = info.maximum();
let default = info.default();
assert!(
model
.runtime_mut()
.set_parameter_override_normalized_by_index(index, 1.0)
);
model.runtime_mut().reset_parameters();
model.runtime_mut().apply_parameter_overrides();
assert_close(
model.runtime().parameter_value_by_index(index).unwrap(),
maximum,
);
assert_close(
model
.runtime()
.parameter_override_normalized_value_by_index(index)
.unwrap(),
1.0,
);
assert!(model.runtime_mut().clear_parameter_override_by_index(index));
model.runtime_mut().reset_parameters();
model.runtime_mut().apply_parameter_overrides();
assert_close(
model.runtime().parameter_value_by_index(index).unwrap(),
default,
);
assert!(
model
.runtime()
.parameter_override_value_by_index(index)
.is_none()
);
}
#[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 expression_player_applies_faded_expression_parameters() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let expression = Expression3::from_json_str(
r#"{
"Type": "Live2D Expression",
"Parameters": [
{ "Id": "ParamAngleX", "Value": 10.0, "Blend": "Add" }
]
}"#,
)
.unwrap();
let index = model.runtime().parameter_index("ParamAngleX").unwrap();
let default = model.runtime().parameter_value_by_index(index).unwrap();
let mut player = ExpressionPlayer::new(expression);
player.tick(0.5);
player.apply(model.runtime_mut());
let value = model.runtime().parameter_value_by_index(index).unwrap();
assert_close(value, default + 5.0);
}
#[test]
fn expression_manager_fades_out_previous_expression_when_playing_next() {
let mut model = load_model_runtime("assets/models/Haru/Haru.model3.json").unwrap();
let first = Expression3::from_json_str(
r#"{
"Type": "Live2D Expression",
"Parameters": [
{ "Id": "ParamAngleX", "Value": 10.0, "Blend": "Add" }
]
}"#,
)
.unwrap();
let second = Expression3::from_json_str(
r#"{
"Type": "Live2D Expression",
"Parameters": [
{ "Id": "ParamAngleX", "Value": -4.0, "Blend": "Add" }
]
}"#,
)
.unwrap();
let index = model.runtime().parameter_index("ParamAngleX").unwrap();
let default = model.runtime().parameter_value_by_index(index).unwrap();
let mut manager = ExpressionManager::new();
manager.play(first);
manager.tick(1.0);
model.runtime_mut().reset_parameters();
manager.apply(model.runtime_mut());
assert_close(
model.runtime().parameter_value_by_index(index).unwrap(),
default + 10.0,
);
manager.play(second);
manager.tick(0.5);
model.runtime_mut().reset_parameters();
manager.apply(model.runtime_mut());
let value = model.runtime().parameter_value_by_index(index).unwrap();
assert_close(value, default + (10.0 * 0.5) + (-4.0 * 0.5));
assert_eq!(manager.active_expression_count(), 2);
manager.tick(0.5);
assert_eq!(manager.active_expression_count(), 1);
}
#[test]
fn load_expression_reads_exp3_asset() {
let expression = load_expression("assets/models/Haru/expressions/F01.exp3.json").unwrap();
assert_eq!(expression.kind(), "Live2D Expression");
assert_eq!(expression.parameters()[0].id(), "ParamMouthForm");
}
#[test]
fn mao_drawable_colors_match_core_default_pose() {
let model = load_model_runtime("assets/models/Mao/Mao.model3.json").unwrap();
let meshes = model.runtime().meshes();
assert_color_close(meshes[45].multiply_color(), [1.0, 1.0, 1.0]);
assert_color_close(meshes[45].screen_color(), [0.0, 0.0, 0.0]);
assert_color_close(meshes[138].multiply_color(), [1.0, 1.0, 1.0]);
assert_color_close(meshes[138].screen_color(), [1.0, 0.454_901_96, 0.513_725_5]);
}
#[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");
}
fn assert_color_close(actual: [f32; 3], expected: [f32; 3]) {
for (actual, expected) in actual.into_iter().zip(expected) {
assert!((actual - expected).abs() < 0.0001);
}
}
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.0001,
"actual {actual}, expected {expected}"
);
}