Files
Marcos Paulo cd43ea488e
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: notescraft-lottie player crate, egui lottie_icon painter, icon_sheet review example, first icon animation set
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:42:57 -03:00

218 lines
7.6 KiB
Rust

//! Hand-calculated geometry checks for the renderer-independent draw list.
use notescraft_lottie::{Animation, Cap, Frame, Item, Join, StrokePath};
use serde_json::{Value, json};
const STROKE: &str = include_str!("fixtures/stroke.json");
const TRIM: &str = include_str!("fixtures/trim.json");
const KEYFRAMES: &str = include_str!("fixtures/keyframes.json");
const HIERARCHY: &str = include_str!("fixtures/hierarchy.json");
const SHACKLE: &str = include_str!("fixtures/shackle.json");
fn near(actual: f32, expected: f32) {
assert!((actual - expected).abs() < 0.015, "{actual} != {expected}");
}
fn stroke(frame: &Frame) -> &StrokePath {
match frame.items.first().expect("one paint item") {
Item::Stroke(path) => path,
Item::Fill(_) => panic!("expected stroke"),
}
}
fn first(frame: &Frame) -> [f32; 2] {
*stroke(frame).polylines[0].points.first().expect("point")
}
fn last(frame: &Frame) -> [f32; 2] {
*stroke(frame).polylines[0].points.last().expect("point")
}
fn parse_value(value: &Value) -> Animation {
Animation::parse(&value.to_string()).expect("valid fixture variation")
}
#[test]
fn stroke_preserves_geometry_style_and_multiplies_opacity() {
let animation = Animation::parse(STROKE).expect("fixture");
let frame = animation.frame_at(5.0);
near(frame.width, 24.0);
near(frame.height, 24.0);
assert_eq!(frame.items.len(), 1);
let path = stroke(&frame);
near(path.width, 2.0);
near(path.opacity, 0.4);
assert!(matches!(path.cap, Cap::Round));
assert!(matches!(path.join, Join::Bevel));
assert!(!path.polylines[0].closed);
near(first(&frame)[0], 2.0);
near(first(&frame)[1], 4.0);
near(last(&frame)[0], 22.0);
}
#[test]
fn cap_and_join_numeric_codes_follow_lottie() {
let mut value: Value = serde_json::from_str(STROKE).expect("fixture");
for code in 1..=3 {
value["layers"][0]["shapes"][1]["lc"] = json!(code);
value["layers"][0]["shapes"][1]["lj"] = json!(code);
let frame = parse_value(&value).frame_at(0.0);
let path = stroke(&frame);
assert!(matches!((code, &path.cap, &path.join), (1, Cap::Butt, Join::Miter) | (2, Cap::Round, Join::Round) | (3, Cap::Square, Join::Bevel)));
}
}
#[test]
fn closed_fill_produces_fill_item_and_layer_opacity() {
let mut value: Value = serde_json::from_str(STROKE).expect("fixture");
value["layers"][0]["shapes"][0]["ks"]["k"] = json!({
"v": [[2, 2], [12, 2], [12, 12]],
"i": [[0, 0], [0, 0], [0, 0]],
"o": [[0, 0], [0, 0], [0, 0]], "c": true
});
value["layers"][0]["shapes"][1] = json!({
"ty": "fl", "c": {"a": 0, "k": [1, 0, 0, 1]},
"o": {"a": 0, "k": 50}, "r": 1
});
let frame = parse_value(&value).frame_at(0.0);
let Item::Fill(path) = &frame.items[0] else { panic!("fill item") };
near(path.opacity, 0.4);
assert!(path.polylines[0].closed);
assert!(path.polylines[0].points.len() >= 3);
}
#[test]
fn trim_draw_on_uses_arc_length_at_start_middle_and_end() {
let animation = Animation::parse(TRIM).expect("fixture");
let start = animation.frame_at(0.0);
let visible_points: usize = start
.items
.iter()
.map(|item| match item {
Item::Stroke(path) => path.polylines.iter().map(|line| line.points.len()).sum::<usize>(),
Item::Fill(path) => path.polylines.iter().map(|line| line.points.len()).sum::<usize>(),
})
.sum();
assert!(visible_points <= 1, "zero trim must not draw a segment");
near(last(&animation.frame_at(5.0))[0], 5.0);
near(last(&animation.frame_at(10.0))[0], 10.0);
}
#[test]
fn temporal_bezier_easing_changes_position_from_linear_midpoint() {
let animation = Animation::parse(KEYFRAMES).expect("fixture");
let point = first(&animation.frame_at(5.0));
// Cubic-bezier(.42, 0, 1, 1) at x=.5 is about .31536.
// The local x=2 also scales to 3; rotation holds at zero.
near(point[0], 6.1536);
near(point[1], 0.0);
}
#[test]
fn hold_rotation_jumps_at_keyframe_and_linear_scale_reaches_endpoint() {
let animation = Animation::parse(KEYFRAMES).expect("fixture");
let before = first(&animation.frame_at(9.0));
near(before[1], 0.0);
let endpoint = first(&animation.frame_at(10.0));
near(endpoint[0], 10.0);
near(endpoint[1], 4.0);
}
#[test]
fn layer_start_time_offsets_animation_keyframes() {
let mut value: Value = serde_json::from_str(KEYFRAMES).expect("fixture");
value["layers"][0]["st"] = json!(2);
let point = first(&parse_value(&value).frame_at(7.0));
near(point[0], 6.1536);
}
#[test]
fn parent_and_group_transforms_compose_with_anchor_and_opacity() {
let frame = Animation::parse(HIERARCHY).expect("fixture").frame_at(0.0);
assert_eq!(frame.items.len(), 1, "null parent never paints");
let point = first(&frame);
near(point[0], 10.0);
near(point[1], 6.0);
near(last(&frame)[1], 8.0);
near(stroke(&frame).opacity, 0.2);
}
#[test]
fn curved_shackle_flattens_cubics_and_rotates_about_hinge() {
let animation = Animation::parse(SHACKLE).expect("fixture");
let closed = animation.frame_at(0.0);
let open = animation.frame_at(10.0);
let line = &stroke(&closed).polylines[0];
assert!(line.points.len() > 4, "cubic must contain interior samples");
assert!(line.points.iter().any(|point| point[1] < 5.0));
near(first(&open)[0], 8.0);
near(first(&open)[1], 12.0);
near(last(&open)[0], 8.0);
near(last(&open)[1], 4.0);
}
#[test]
fn layer_visibility_uses_inclusive_in_and_exclusive_out_frame() {
let mut value: Value = serde_json::from_str(STROKE).expect("fixture");
value["layers"][0]["ip"] = json!(2);
value["layers"][0]["op"] = json!(8);
let animation = parse_value(&value);
assert_eq!(animation.frame_at(1.0).items, Vec::new());
assert_eq!(animation.frame_at(2.0).items.len(), 1);
assert_eq!(animation.frame_at(8.0).items, Vec::new());
}
#[test]
fn separated_position_components_animate_independently() {
let mut value: Value = serde_json::from_str(STROKE).expect("fixture");
value["layers"][0]["ks"]["p"] = json!({
"s": true,
"x": {"a": 1, "k": [
{"t": 0, "s": [0], "e": [10], "o": {"x": [0], "y": [0]}},
{"t": 10, "s": [10], "i": {"x": [1], "y": [1]}}
]},
"y": {"a": 0, "k": 6}
});
let frame = parse_value(&value).frame_at(5.0);
near(first(&frame)[0], 7.0);
near(first(&frame)[1], 10.0);
}
#[test]
fn topmost_lottie_layer_is_painted_last() {
let mut value: Value = serde_json::from_str(STROKE).expect("fixture");
let mut back = value["layers"][0].clone();
back["ind"] = json!(2);
back["ks"]["p"]["k"] = json!([10, 0]);
value["layers"].as_array_mut().expect("layers").push(back);
let frame = parse_value(&value).frame_at(0.0);
let starts: Vec<_> = frame
.items
.iter()
.flat_map(|item| match item {
Item::Stroke(path) => &path.polylines,
Item::Fill(path) => &path.polylines,
})
.filter_map(|line| line.points.first())
.collect();
assert_eq!(starts.len(), 2);
near(starts[0][0], 12.0);
near(starts[1][0], 2.0);
}
#[test]
fn frame_requests_clamp_to_composition_and_nonfinite_uses_first_frame() {
let mut value: Value = serde_json::from_str(KEYFRAMES).expect("fixture");
value["op"] = json!(10);
value["layers"][0]["op"] = json!(20);
let animation = parse_value(&value);
for requested in [-10.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let point = first(&animation.frame_at(requested));
near(point[0], 2.0);
near(point[1], 0.0);
}
let point = first(&animation.frame_at(100.0));
near(point[0], 10.0);
near(point[1], 4.0);
}