The widget half of the widget catalogue's later work, ported onto makepad-widgets-core and the family crates. - libs/tween: a dependency-free tween and timeline engine -- nested timelines with positions and labels, stagger, repeat and yoyo, seek and reverse, time scale, a ticker with lag smoothing, a global pause and reduced motion, colour-space interpolation, keyframes and motion paths. The core binds it to the widget tree (tween, a widget's TweenHost), gives script a mod.tween (tween_script) and a live inspector (tween_inspect, tween_inspector). The Animator's Ease now answers from the engine's easing, so the curves have one source; the Animator keeps its states, its keyframe timelines and its own clock. - libs/gizmo and a Gizmo3d / ViewCube widget. - core: the ease editor, the sequencer, diagonal headings, hotkeys, the hotkey editor and keyboard map, the overlay layer host, a conceding row and the width a widget would take without drawing; a theme mix and a person's own theme edits that survive a style reload (theme_mix, set_theme_edit), both taken off by set_base_theme. - the surface material: buttons, check boxes, toggles, sliders and knobs carry the moulded material behind one uniform, a press idiom per sign of material_press, and four sheets that raise it: neumorphic, molded, glossy and milled. - the sheets: a row of fields is one height under every sheet. A sheet line that names a widget the app did not register (a family left out) is skipped when the sheet installs, so the date fields a sheet sizes need the dates family only where it is linked. - extras: the gradient editor, registered with the colour picker it builds on. - tweaker: the designer (a widget's literal located and edited in its source as hunks), the theme lab and the theme builder. The window reads the docked panel's band through the tweaker's hooks. - the panel no longer wears the app's theme (the design surface keeps its own palette), so the worn-theme tests go with it. Five theme builder tests fail here as they do on the branch this comes from (every_built_theme_reads and four more on text contrast); radial_menu's tab_under_an_open_menu_finds_nowhere_to_go fails here as it does on dev.
1241 lines
41 KiB
Rust
1241 lines
41 KiB
Rust
//! Motion paths (libs/tween/src/path.rs) and the engine's `PropTo::path`:
|
|
//! the SVG parser, the arc-length table, tangents and headings, wrap and
|
|
//! spans, `through` / `cubic_points`, 3D, and path tweens (exact ends,
|
|
//! clamped overshoot, align, offset, overwrite, stagger, keyframes,
|
|
//! `sample`, the path lifecycle, repeat refresh).
|
|
|
|
mod util;
|
|
|
|
use makepad_tween::*;
|
|
use std::f64::consts::PI;
|
|
use util::*;
|
|
|
|
const R: PropKey = PropKey(10);
|
|
const Z: PropKey = PropKey(11);
|
|
const K: PropKey = PropKey(12);
|
|
|
|
fn svg(d: &str) -> MotionPath {
|
|
MotionPath::from_svg(d).unwrap_or_else(|e| panic!("{d}: {e}"))
|
|
}
|
|
|
|
#[track_caller]
|
|
fn close2(a: [f64; 3], b: [f64; 2], tol: f64, what: &str) {
|
|
close(a[0], b[0], tol, what);
|
|
close(a[1], b[1], tol, what);
|
|
}
|
|
|
|
fn speed(s: &PathSeg, t: f64) -> f64 {
|
|
let d = s.derivative(t);
|
|
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
|
|
}
|
|
|
|
/// The test's own arc length: adaptive Simpson on |B'|.
|
|
fn simpson(s: &PathSeg, a: f64, b: f64, eps: f64) -> f64 {
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn rec(
|
|
s: &PathSeg,
|
|
a: f64,
|
|
b: f64,
|
|
fa: f64,
|
|
fm: f64,
|
|
fb: f64,
|
|
whole: f64,
|
|
eps: f64,
|
|
depth: u32,
|
|
) -> f64 {
|
|
let m = 0.5 * (a + b);
|
|
let (lm, rm) = (0.5 * (a + m), 0.5 * (m + b));
|
|
let (flm, frm) = (speed(s, lm), speed(s, rm));
|
|
let left = (m - a) / 6.0 * (fa + 4.0 * flm + fm);
|
|
let right = (b - m) / 6.0 * (fm + 4.0 * frm + fb);
|
|
let d = left + right - whole;
|
|
if depth > 40 || d.abs() <= 15.0 * eps {
|
|
left + right + d / 15.0
|
|
} else {
|
|
rec(s, a, m, fa, flm, fm, left, eps / 2.0, depth + 1)
|
|
+ rec(s, m, b, fm, frm, fb, right, eps / 2.0, depth + 1)
|
|
}
|
|
}
|
|
let (fa, fm, fb) = (speed(s, a), speed(s, 0.5 * (a + b)), speed(s, b));
|
|
let whole = (b - a) / 6.0 * (fa + 4.0 * fm + fb);
|
|
rec(s, a, b, fa, fm, fb, whole, eps, 0)
|
|
}
|
|
|
|
/// Arc length along `path` between two sampled points (p before q).
|
|
fn arc_between(path: &MotionPath, p: &PathPoint, q: &PathPoint) -> f64 {
|
|
if p.seg == q.seg {
|
|
return simpson(&path.segment(p.seg), p.t, q.t, 1e-12);
|
|
}
|
|
let mut sum = simpson(&path.segment(p.seg), p.t, 1.0, 1e-12);
|
|
for i in p.seg + 1..q.seg {
|
|
sum += simpson(&path.segment(i), 0.0, 1.0, 1e-12);
|
|
}
|
|
sum + simpson(&path.segment(q.seg), 0.0, q.t, 1e-12)
|
|
}
|
|
|
|
fn circle() -> MotionPath {
|
|
svg("M100,50 A50,50 0 1,1 0,50 A50,50 0 1,1 100,50 Z")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The SVG parser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn svg_lines_and_relative_forms() {
|
|
let p = svg("M0,0 L10,0");
|
|
assert_eq!(p.length(), 10.0);
|
|
assert!(!p.is_closed());
|
|
|
|
let p = svg("M0 0 H10 V10 h-10 z");
|
|
assert!(p.is_closed());
|
|
assert_eq!(p.segment_count(), 4);
|
|
assert_eq!(p.subpath_count(), 1);
|
|
assert_eq!(p.length(), 40.0);
|
|
assert_eq!(p.segment(3).end(), [0.0, 0.0, 0.0]);
|
|
|
|
let p = svg("m5,5 l5,0");
|
|
assert_eq!(p.start_point(), [5.0, 5.0, 0.0]);
|
|
assert_eq!(p.end_point(), [10.0, 5.0, 0.0]);
|
|
|
|
// Implicit L after M.
|
|
let p = svg("M0,0 10,0 10,10");
|
|
assert_eq!(p.segment_count(), 2);
|
|
assert!(p.segment(1).kind == SegKind::Line);
|
|
assert_eq!(p.end_point(), [10.0, 10.0, 0.0]);
|
|
|
|
// Implicit l after m; relative c groups share their start.
|
|
let p = svg("m1,1 2,0 c1,0 2,1 3,0 1,0 2,-1 3,0");
|
|
assert_eq!(p.segment(0).end(), [3.0, 1.0, 0.0]);
|
|
assert_eq!(p.segment(1).p[1], [4.0, 1.0, 0.0]);
|
|
assert_eq!(p.segment(1).p[2], [5.0, 2.0, 0.0]);
|
|
assert_eq!(p.segment(1).end(), [6.0, 1.0, 0.0]);
|
|
assert_eq!(p.segment(2).p[1], [7.0, 1.0, 0.0]);
|
|
assert_eq!(p.end_point(), [9.0, 1.0, 0.0]);
|
|
|
|
// After Z a drawing command starts a new subpath at the old start.
|
|
let p = svg("M0,0 L10,0 L10,10 Z L0,20");
|
|
assert_eq!(p.subpath_count(), 2);
|
|
assert!(!p.is_closed());
|
|
assert_eq!(p.segment(3).start(), [0.0, 0.0, 0.0]);
|
|
assert_eq!(p.segment(3).subpath, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn svg_number_forms() {
|
|
let p = svg("M1.5.5L-2-2");
|
|
assert_eq!(p.start_point(), [1.5, 0.5, 0.0]);
|
|
assert_eq!(p.end_point(), [-2.0, -2.0, 0.0]);
|
|
let p = svg("M1e1,0 L0,0");
|
|
assert_eq!(p.start_point(), [10.0, 0.0, 0.0]);
|
|
let p = svg("M+1.,-.5E1\t\n\r\x0cL2,3");
|
|
assert_eq!(p.start_point(), [1.0, -5.0, 0.0]);
|
|
// Arc flags abut the next token.
|
|
let p = svg("M0,0 a25 25 0 1050 0");
|
|
assert_eq!(p.segment_count(), 2);
|
|
assert_eq!(p.end_point(), [50.0, 0.0, 0.0]);
|
|
close(p.length(), PI * 25.0, 0.012, "half circle r 25");
|
|
}
|
|
|
|
#[test]
|
|
fn svg_s_and_t_reflect() {
|
|
// S after C reflects c2 about the current point, exactly.
|
|
let p = svg("M0,0 C10,20 30,20 40,0 S70,-20 80,0");
|
|
assert_eq!(p.segment(1).p[1], [50.0, -20.0, 0.0]);
|
|
assert_eq!(p.segment(1).p[2], [70.0, -20.0, 0.0]);
|
|
// S after S reflects the previous S.
|
|
let p = svg("M0,0 C10,20 30,20 40,0 S70,-20 80,0 S110,20 120,0");
|
|
assert_eq!(p.segment(2).p[1], [90.0, 20.0, 0.0]);
|
|
// T after Q, and T after T (its implied control).
|
|
let p = svg("M0,0 Q10,20 20,0 T40,0 T60,0");
|
|
assert_eq!(p.segment(1).kind, SegKind::Quad);
|
|
assert_eq!(p.segment(1).p[1], [30.0, -20.0, 0.0]);
|
|
assert_eq!(p.segment(2).p[1], [50.0, 20.0, 0.0]);
|
|
// S after L, T after C: the control is the current point.
|
|
let p = svg("M0,0 L10,0 S20,10 30,0");
|
|
assert_eq!(p.segment(1).p[1], [10.0, 0.0, 0.0]);
|
|
let p = svg("M0,0 C0,10 10,10 10,0 T30,0");
|
|
assert_eq!(p.segment(1).p[1], [10.0, 0.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn svg_arc_half_circle() {
|
|
let p = svg("M0,0 A50,50 0 0,1 100,0");
|
|
assert_eq!(p.segment_count(), 2);
|
|
assert!(p.segment(0).kind == SegKind::Cubic);
|
|
assert_eq!(p.end_point(), [100.0, 0.0, 0.0]);
|
|
close2(p.sample(0.5).pos, [50.0, -50.0], 1e-9, "arc midpoint");
|
|
// Two 90-degree cubics are 1.4e-4 longer than the arc.
|
|
close(p.length(), PI * 50.0, 0.025, "arc length");
|
|
let b = p.bounds();
|
|
close2(b.min, [0.0, -50.0], 1e-9, "bounds min");
|
|
close2(b.max, [100.0, 0.0], 1e-9, "bounds max");
|
|
// Rotated ellipse ends exactly on its endpoint too.
|
|
let p = svg("M10,10 A40,20 30 1,0 70,40");
|
|
assert_eq!(p.end_point(), [70.0, 40.0, 0.0]);
|
|
assert!(p.segment_count() >= 3);
|
|
}
|
|
|
|
#[test]
|
|
fn svg_arc_degenerate() {
|
|
// A zero radius draws a line.
|
|
let p = svg("M0,0 A0,10 0 0,1 10,0");
|
|
assert_eq!(p.segment_count(), 1);
|
|
assert_eq!(p.segment(0).kind, SegKind::Line);
|
|
// The same endpoint draws nothing.
|
|
let p = svg("M0,0 L10,0 A5,5 0 0,1 10,0");
|
|
assert_eq!(p.segment_count(), 1);
|
|
// Radii too small for the chord are scaled up (to a half circle).
|
|
let p = svg("M0,0 A10,10 0 0,1 100,0");
|
|
close2(
|
|
p.sample(0.5).pos,
|
|
[50.0, -50.0],
|
|
1e-9,
|
|
"scaled arc midpoint",
|
|
);
|
|
// Negative radii count as their magnitude.
|
|
let q = svg("M0,0 A-10,-10 0 0,1 100,0");
|
|
assert_eq!(p.sample(0.3).pos, q.sample(0.3).pos);
|
|
}
|
|
|
|
#[test]
|
|
fn svg_errors() {
|
|
use PathError::*;
|
|
let err = |d: &str| MotionPath::from_svg(d).unwrap_err();
|
|
assert_eq!(err(""), Empty);
|
|
assert_eq!(err(" , \n"), Empty);
|
|
assert_eq!(err("M0,0"), Empty);
|
|
assert_eq!(err("L10,10"), NoCurrentPoint { at: 0 });
|
|
assert_eq!(err(" c1,1 2,2 3,3"), NoCurrentPoint { at: 2 });
|
|
assert_eq!(err("M0,0 X"), UnknownCommand { at: 5, cmd: b'X' });
|
|
assert_eq!(err("M0,0 L10"), Syntax { at: 8 });
|
|
assert_eq!(err("M0,0 L10 L5,5"), Syntax { at: 9 });
|
|
assert_eq!(err("M0,0 L1e999,0"), NotFinite);
|
|
assert_eq!(err("M5,5 L5,5"), ZeroLength);
|
|
assert_eq!(err("M0,0 Z 5"), Syntax { at: 7 });
|
|
assert_eq!(err("5 M0,0"), Syntax { at: 0 });
|
|
assert_eq!(err("M1e,0 L1,1"), Syntax { at: 1 });
|
|
assert_eq!(err("M0,0 . L1,1"), Syntax { at: 5 });
|
|
assert_eq!(err("M0,0 A5,5 0 2,0 9,9"), Syntax { at: 12 });
|
|
assert_eq!(err("M0,0 #"), UnknownCommand { at: 5, cmd: b'#' });
|
|
let text = err("M0,0 X").to_string();
|
|
assert!(
|
|
text.starts_with("motion path: ") && text.ends_with("at byte 5"),
|
|
"{text}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The arc-length table
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn test_cubic() -> MotionPath {
|
|
let mut b = MotionPath::builder();
|
|
b.move_to(0.0, 0.0)
|
|
.cubic_to(0.0, 200.0, 300.0, -100.0, 300.0, 100.0);
|
|
b.finish().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn equal_u_steps_are_equal_arc_lengths() {
|
|
let p = test_cubic();
|
|
let total = simpson(&p.segment(0), 0.0, 1.0, 1e-12);
|
|
close(p.length(), total, 1e-9, "table length vs Simpson");
|
|
let step = p.length() / 64.0;
|
|
let mut prev = p.sample(0.0);
|
|
for k in 1..=64 {
|
|
let q = p.sample(k as f64 / 64.0);
|
|
let d = arc_between(&p, &prev, &q);
|
|
close(d, step, 1e-6, &format!("step {k}"));
|
|
prev = q;
|
|
}
|
|
// sample_at_length agrees with sample.
|
|
let a = p.sample_at_length(p.length() * 0.3);
|
|
let b = p.sample(0.3);
|
|
close2(a.pos, [b.pos[0], b.pos[1]], 1e-9, "at_length vs sample");
|
|
}
|
|
|
|
#[test]
|
|
fn span_counts_follow_the_density_rule() {
|
|
let lines = MotionPath::polyline(&[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], false).unwrap();
|
|
assert_eq!(lines.span_count(), 2);
|
|
let c = test_cubic();
|
|
let n = c.span_count();
|
|
assert!((PATH_SPANS_MIN..=PATH_SPANS_MAX).contains(&n), "{n}");
|
|
assert!(
|
|
n > PATH_SPANS_MIN,
|
|
"a curve that bends this much refines: {n}"
|
|
);
|
|
// A straight cubic with even handles needs no refinement.
|
|
let mut b = MotionPath::builder();
|
|
b.move_to(0.0, 0.0)
|
|
.cubic_to(10.0, 0.0, 20.0, 0.0, 30.0, 0.0);
|
|
assert_eq!(b.finish().unwrap().span_count(), PATH_SPANS_MIN);
|
|
let s = svg("M20,160 C120,-20 220,220 320,100 S520,0 580,160 A60,60 0 0,1 460,160 L400,120 Q340,60 300,140 T160,160 Z");
|
|
let curves = (0..s.segment_count())
|
|
.filter(|&i| s.segment(i).kind != SegKind::Line)
|
|
.count() as u32;
|
|
let lines = s.segment_count() - curves;
|
|
assert!(s.span_count() >= lines + curves * PATH_SPANS_MIN);
|
|
assert!(s.span_count() <= lines + curves * PATH_SPANS_MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn ends_are_exact() {
|
|
let wave = MotionPath::through(
|
|
&[[0.0, 100.0], [100.0, 20.0], [200.0, 180.0], [300.0, 60.0]],
|
|
1.0,
|
|
)
|
|
.unwrap();
|
|
for p in [test_cubic(), wave, svg("M3,4 Q10,-7 20,5 L31,9")] {
|
|
assert_eq!(p.sample(0.0).pos, p.start_point());
|
|
assert_eq!(p.sample(1.0).pos, p.end_point());
|
|
assert_eq!(p.sample(0.0).t, 0.0);
|
|
assert_eq!(p.sample(1.0).t, 1.0);
|
|
assert_eq!(p.sample_at_length(p.length()).pos, p.end_point());
|
|
assert_eq!(p.sample_span(0.2, 0.7, 1.0), p.sample(0.7));
|
|
}
|
|
// A span boundary (here a line's end) is hit exactly.
|
|
let p = MotionPath::polyline(&[[0.0, 0.0], [10.0, 0.0], [10.0, 30.0]], false).unwrap();
|
|
let q = p.sample(0.25);
|
|
assert_eq!((q.pos, q.seg, q.t), ([10.0, 0.0, 0.0], 0, 1.0));
|
|
assert_eq!(p.sample(0.625).pos, [10.0, 15.0, 0.0]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tangents and headings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn tangents_and_headings() {
|
|
let p = svg("M0,0 L10,10");
|
|
let s = p.sample(0.5);
|
|
close(s.tangent[0], 0.5f64.sqrt(), 1e-15, "tx");
|
|
close(s.tangent[1], 0.5f64.sqrt(), 1e-15, "ty");
|
|
close(s.angle_deg, 45.0, 1e-12, "45");
|
|
close(svg("M0,0 L0,10").sample(0.3).angle_deg, 90.0, 1e-12, "90");
|
|
close(svg("M10,0 L0,0").sample(0.3).angle_deg, 180.0, 1e-12, "180");
|
|
// A cusp at the start (zero first handle): toward c2.
|
|
let c = svg("M0,0 C0,0 10,10 20,0").sample(0.0);
|
|
close(c.tangent[0], 0.5f64.sqrt(), 1e-12, "cusp tx");
|
|
close(c.tangent[1], 0.5f64.sqrt(), 1e-12, "cusp ty");
|
|
// A zero last handle: from c1.
|
|
let seg = svg("M0,0 C10,10 20,0 20,0").segment(0);
|
|
let t = seg.tangent(1.0);
|
|
close(t[0], 0.5f64.sqrt(), 1e-12, "end cusp tx");
|
|
close(t[1], -(0.5f64.sqrt()), 1e-12, "end cusp ty");
|
|
// Wrapped angle.
|
|
let pt = PathPoint {
|
|
angle_deg: 540.0,
|
|
..PathPoint::default()
|
|
};
|
|
assert_eq!(pt.angle_wrapped(), 180.0);
|
|
let pt = PathPoint {
|
|
angle_deg: -190.0,
|
|
..PathPoint::default()
|
|
};
|
|
close(pt.angle_wrapped(), 170.0, 1e-12, "wrap -190");
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_circle_turns_360_degrees() {
|
|
let p = circle();
|
|
assert!(p.is_closed());
|
|
assert_eq!(p.segment_count(), 4);
|
|
close(p.sample(0.0).angle_deg, 90.0, 1e-9, "start angle");
|
|
close(p.total_turn(), 360.0, 1e-9, "total turn");
|
|
close2(p.sample(0.25).pos, [50.0, 100.0], 1e-9, "quarter");
|
|
close2(p.sample(0.5).pos, [0.0, 50.0], 1e-9, "half");
|
|
close(p.length(), PI * 100.0, 0.05, "circumference");
|
|
let mut prev = f64::NEG_INFINITY;
|
|
for k in 0..=200 {
|
|
let a = p.sample(k as f64 / 200.0).angle_deg;
|
|
assert!(a > prev, "angle not increasing at {k}: {a} after {prev}");
|
|
prev = a;
|
|
}
|
|
close(prev, 450.0, 1e-9, "end angle");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wrap and spans
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn closed_paths_wrap_and_open_paths_clamp() {
|
|
let c = circle();
|
|
let a = c.sample(0.25);
|
|
let b = c.sample(1.25);
|
|
assert_eq!(a.pos, b.pos);
|
|
close(b.angle_deg, a.angle_deg + 360.0, 1e-9, "one lap on");
|
|
let n = c.sample(-0.75);
|
|
assert_eq!(n.pos, a.pos);
|
|
close(n.angle_deg, a.angle_deg - 360.0, 1e-9, "one lap back");
|
|
assert_eq!(c.sample(2.0).pos, c.end_point());
|
|
close(
|
|
c.sample(2.0).angle_deg,
|
|
c.sample(1.0).angle_deg + 360.0,
|
|
1e-9,
|
|
"u 2",
|
|
);
|
|
// Span (0.75, 1.25) runs over the seam.
|
|
close2(
|
|
c.sample_span(0.75, 1.25, 0.5).pos,
|
|
[100.0, 50.0],
|
|
1e-9,
|
|
"seam",
|
|
);
|
|
assert_eq!(c.sample_span(0.75, 1.25, 1.0), c.sample(1.25));
|
|
|
|
let l = svg("M0,0 L100,0");
|
|
assert_eq!(l.sample(-0.5), l.sample(0.0));
|
|
assert_eq!(l.sample(1.5), l.sample(1.0));
|
|
assert_eq!(l.sample(f64::NAN), l.sample(0.0));
|
|
close(l.sample_span(0.2, 0.8, 0.5).pos[0], 50.0, 1e-12, "span mid");
|
|
let r = l.sample_span(0.8, 0.2, 0.0);
|
|
close(r.pos[0], 80.0, 1e-12, "reversed start");
|
|
assert_eq!(r.tangent, [-1.0, -0.0, -0.0]);
|
|
close(r.angle_deg, 180.0, 1e-12, "reversed angle");
|
|
close(
|
|
l.sample_span(0.8, 0.2, 1.0).pos[0],
|
|
20.0,
|
|
1e-12,
|
|
"reversed end",
|
|
);
|
|
// The eased ratio is clamped; non-finite fractions count as 0 and 1.
|
|
assert_eq!(l.sample_span(0.2, 0.8, 1.7), l.sample_span(0.2, 0.8, 1.0));
|
|
assert_eq!(l.sample_span(0.2, 0.8, -0.3), l.sample_span(0.2, 0.8, 0.0));
|
|
assert_eq!(l.sample_span(f64::NAN, f64::INFINITY, 1.0), l.sample(1.0));
|
|
// An empty path answers the default point.
|
|
assert_eq!(MotionPath::default().sample(0.5), PathPoint::default());
|
|
}
|
|
|
|
#[test]
|
|
fn subpaths_jump_with_zero_arc_length_and_flatten_in_runs() {
|
|
let p = svg("M0,0 L10,0 M20,0 L30,0");
|
|
assert_eq!(p.subpath_count(), 2);
|
|
assert!(!p.is_closed());
|
|
assert_eq!(p.length(), 20.0);
|
|
assert_eq!(p.sample(0.5).pos, [10.0, 0.0, 0.0]);
|
|
assert_eq!(p.sample(0.75).pos, [25.0, 0.0, 0.0]);
|
|
let mut pts = Vec::new();
|
|
p.flatten(8, |q, first| pts.push((q[0], first)));
|
|
assert_eq!(
|
|
pts,
|
|
vec![(0.0, true), (10.0, false), (20.0, true), (30.0, false)]
|
|
);
|
|
let wave = MotionPath::through(&[[0.0, 0.0], [10.0, 5.0], [20.0, 0.0]], 1.0).unwrap();
|
|
let mut n = 0;
|
|
let mut last = [0.0; 3];
|
|
wave.flatten(24, |q, first| {
|
|
assert_eq!(first, n == 0);
|
|
n += 1;
|
|
last = q;
|
|
});
|
|
assert_eq!(n, 1 + 2 * 24);
|
|
assert_eq!(last, wave.end_point());
|
|
}
|
|
|
|
#[test]
|
|
fn translate_moves_points_and_bounds() {
|
|
let p = svg("M0,0 A50,50 0 0,1 100,0");
|
|
let mut q = p.clone();
|
|
q.translate(5.0, -3.0);
|
|
assert_eq!(q.length(), p.length());
|
|
let (a, b) = (p.sample(0.4).pos, q.sample(0.4).pos);
|
|
close(b[0], a[0] + 5.0, 1e-12, "x");
|
|
close(b[1], a[1] - 3.0, 1e-12, "y");
|
|
close2(q.bounds().min, [5.0, -53.0], 1e-9, "min");
|
|
close2(q.bounds().size(), [100.0, 50.0], 1e-9, "size");
|
|
close2(q.bounds().center(), [55.0, -28.0], 1e-9, "center");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// through / cubic_points / polyline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn thru_matches_hand_computed_cubics() {
|
|
let pts = [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0]];
|
|
let p = MotionPath::through(&pts, 1.0).unwrap();
|
|
assert_eq!(p.segment_count(), 2);
|
|
let (s0, s1) = (p.segment(0), p.segment(1));
|
|
let h = 23.57022603955158;
|
|
close2(s0.p[1], [33.333333333333336, 0.0], 1e-12, "seg0 c1");
|
|
close2(s0.p[2], [100.0 - h, -h], 1e-12, "seg0 c2");
|
|
close2(s1.p[1], [100.0 + h, h], 1e-12, "seg1 c1");
|
|
close2(s1.p[2], [100.0, 66.66666666666667], 1e-12, "seg1 c2");
|
|
let p2 = MotionPath::through(&pts, 2.0).unwrap();
|
|
close2(
|
|
p2.segment(0).p[1],
|
|
[66.66666666666667, 0.0],
|
|
1e-12,
|
|
"k 2 c1",
|
|
);
|
|
close2(
|
|
p2.segment(1).p[1],
|
|
[100.0 + 2.0 * h, 2.0 * h],
|
|
1e-12,
|
|
"k 2 seg1 c1",
|
|
);
|
|
let p0 = MotionPath::through(&pts, 0.0).unwrap();
|
|
assert_eq!(p0.segment_count(), 2);
|
|
assert!((0..2).all(|i| p0.segment(i).kind == SegKind::Line));
|
|
assert_eq!(p0.length(), 200.0);
|
|
// Negative curviness counts as 0; NaN is an error.
|
|
assert_eq!(
|
|
MotionPath::through(&pts, -1.0).unwrap().segment(0).kind,
|
|
SegKind::Line
|
|
);
|
|
assert_eq!(
|
|
MotionPath::through(&pts, f64::NAN).unwrap_err(),
|
|
PathError::NotFinite
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thru_closes_when_first_meets_last() {
|
|
let sq = [
|
|
[0.0, 0.0],
|
|
[100.0, 0.0],
|
|
[100.0, 100.0],
|
|
[0.0, 100.0],
|
|
[0.0005, 0.0],
|
|
];
|
|
let p = MotionPath::through(&sq, 1.0).unwrap();
|
|
assert!(p.is_closed());
|
|
assert_eq!(p.segment_count(), 4);
|
|
assert!((0..4).all(|i| p.segment(i).kind == SegKind::Cubic));
|
|
assert_eq!(p.end_point(), [0.0, 0.0, 0.0]);
|
|
// Closed: the tangent at the seam is the bisector of both neighbours.
|
|
let t0 = p.sample(0.0).tangent;
|
|
close(t0[0], 0.5f64.sqrt(), 1e-12, "seam tx");
|
|
close(t0[1], -(0.5f64.sqrt()), 1e-12, "seam ty");
|
|
// Two points never close.
|
|
let two = MotionPath::through(&[[0.0, 0.0], [0.0, 0.0005]], 1.0);
|
|
assert!(!two.unwrap().is_closed());
|
|
}
|
|
|
|
#[test]
|
|
fn thru_drops_duplicates() {
|
|
let p = MotionPath::through(
|
|
&[
|
|
[0.0, 0.0],
|
|
[0.0, 0.0],
|
|
[10.0, 0.0],
|
|
[10.0, 1e-12],
|
|
[20.0, 5.0],
|
|
],
|
|
1.0,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(p.segment_count(), 2);
|
|
assert_eq!(
|
|
MotionPath::through(&[[1.0, 1.0], [1.0, 1.0]], 1.0).unwrap_err(),
|
|
PathError::Empty
|
|
);
|
|
assert_eq!(MotionPath::through(&[], 1.0).unwrap_err(), PathError::Empty);
|
|
assert_eq!(
|
|
MotionPath::through(&[[0.0, f64::INFINITY], [1.0, 1.0]], 1.0).unwrap_err(),
|
|
PathError::NotFinite
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cubic_points_counts() {
|
|
let p =
|
|
MotionPath::cubic_points(&[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [10.0, 0.0]]).unwrap();
|
|
assert_eq!(p.segment_count(), 1);
|
|
assert_eq!(p.segment(0).p[2], [10.0, 10.0, 0.0]);
|
|
assert_eq!(
|
|
MotionPath::cubic_points(&[[0.0, 0.0]; 5]).unwrap_err(),
|
|
PathError::PointCount { got: 5 }
|
|
);
|
|
assert_eq!(
|
|
MotionPath::cubic_points(&[[0.0, 0.0]; 1]).unwrap_err(),
|
|
PathError::PointCount { got: 1 }
|
|
);
|
|
let closed = MotionPath::cubic_points(&[
|
|
[0.0, 0.0],
|
|
[0.0, 10.0],
|
|
[10.0, 10.0],
|
|
[10.0, 0.0],
|
|
[10.0, -10.0],
|
|
[0.0, -10.0],
|
|
[0.0, 0.0],
|
|
])
|
|
.unwrap();
|
|
assert!(closed.is_closed());
|
|
assert_eq!(closed.segment_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn builder_errors_and_subpaths() {
|
|
let mut b = PathBuilder::new();
|
|
b.line_to(1.0, 1.0);
|
|
assert_eq!(b.finish().unwrap_err(), PathError::NoCurrentPoint { at: 0 });
|
|
let mut b = PathBuilder::new();
|
|
b.move_to(0.0, 0.0).line_to(f64::NAN, 1.0).line_to(1.0, 1.0);
|
|
assert_eq!(b.finish().unwrap_err(), PathError::NotFinite);
|
|
// The builder is empty after finish.
|
|
let mut b = PathBuilder::new();
|
|
b.move_to(0.0, 0.0).line_to(1.0, 0.0);
|
|
assert!(b.finish().is_ok());
|
|
assert_eq!(b.current(), None);
|
|
assert_eq!(b.finish().unwrap_err(), PathError::Empty);
|
|
// A move right after a move only moves the start; close adds the line.
|
|
let mut b = PathBuilder::new();
|
|
b.move_to(5.0, 5.0)
|
|
.move_to(0.0, 0.0)
|
|
.line_to(10.0, 0.0)
|
|
.line_to(10.0, 10.0)
|
|
.close();
|
|
assert_eq!(b.current(), Some([0.0, 0.0, 0.0]));
|
|
let p = b.finish().unwrap();
|
|
assert_eq!(
|
|
(p.segment_count(), p.subpath_count(), p.is_closed()),
|
|
(3, 1, true)
|
|
);
|
|
// A zero-length segment is dropped.
|
|
let mut b = PathBuilder::new();
|
|
b.move_to(0.0, 0.0).line_to(0.0, 0.0).line_to(3.0, 4.0);
|
|
let p = b.finish().unwrap();
|
|
assert_eq!((p.segment_count(), p.length()), (1, 5.0));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3D
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Three turns of the helix (cos t, sin t, t / 2pi), `n` cubics per turn:
|
|
/// each cubic takes the circle's optimal handle (4/3 tan(h/4)) in xy and
|
|
/// the exact linear handle in z.
|
|
fn helix(n: u32) -> MotionPath {
|
|
let h = 2.0 * PI / n as f64;
|
|
let k = 4.0 / 3.0 * (h / 4.0).tan();
|
|
let dz = 1.0 / n as f64;
|
|
let mut pts = vec![[1.0, 0.0, 0.0]];
|
|
for i in 0..3 * n {
|
|
let (a0, a1) = (i as f64 * h, (i + 1) as f64 * h);
|
|
let (s0, c0) = a0.sin_cos();
|
|
let (s1, c1) = a1.sin_cos();
|
|
let (z0, z1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);
|
|
pts.push([c0 - k * s0, s0 + k * c0, z0 + dz / 3.0]);
|
|
pts.push([c1 + k * s1, s1 - k * c1, z1 - dz / 3.0]);
|
|
pts.push([c1, s1, z1]);
|
|
}
|
|
MotionPath::cubic_points3(&pts).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_helix_is_measured_in_3d() {
|
|
let p = helix(32);
|
|
let exact = 3.0 * (4.0 * PI * PI + 1.0).sqrt();
|
|
close(p.length(), exact, 1e-6, "helix length");
|
|
assert_eq!(p.end_point()[2], 3.0);
|
|
// Equal u steps are equal arc distances, measured in 3D.
|
|
let step = p.length() / 90.0;
|
|
let mut prev = p.sample(0.0);
|
|
for k in 1..=90 {
|
|
let q = p.sample(k as f64 / 90.0);
|
|
close(
|
|
arc_between(&p, &prev, &q),
|
|
step,
|
|
1e-6,
|
|
&format!("helix step {k}"),
|
|
);
|
|
prev = q;
|
|
}
|
|
// The xy heading turns 3 x 360 degrees; the tangent climbs.
|
|
close(p.total_turn(), 1080.0, 1e-6, "helix turn");
|
|
let t = p.sample(0.5).tangent;
|
|
close(
|
|
t[2],
|
|
1.0 / (4.0 * PI * PI + 1.0).sqrt(),
|
|
1e-3,
|
|
"helix tangent z",
|
|
);
|
|
let b = p.bounds();
|
|
close(b.min[2], 0.0, 0.0, "z min");
|
|
close(b.max[2], 3.0, 0.0, "z max");
|
|
close(b.max[0], 1.0, 1e-9, "x max");
|
|
// A line along z has a defined heading (the last one) and length.
|
|
let v =
|
|
MotionPath::polyline3(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 5.0]], false).unwrap();
|
|
assert_eq!(v.length(), 6.0);
|
|
assert_eq!(v.sample(0.9).angle_deg, 0.0);
|
|
let q = v.sample(0.9).pos;
|
|
close(q[0], 1.0, 1e-12, "x");
|
|
close(q[2], 4.4, 1e-12, "z");
|
|
let mut m = v.clone();
|
|
m.translate3(1.0, 2.0, 3.0);
|
|
assert_eq!(m.end_point(), [2.0, 2.0, 8.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_2d_path_built_in_3d_samples_bitwise_the_same() {
|
|
let flat = |p: &[[f64; 2]]| p.iter().map(|q| [q[0], q[1], 0.0]).collect::<Vec<_>>();
|
|
let pts = [
|
|
[0.0, 100.0],
|
|
[100.0, 20.0],
|
|
[200.0, 180.0],
|
|
[300.0, 60.0],
|
|
[400.0, 160.0],
|
|
];
|
|
let loop_pts = [
|
|
[300.0, 20.0],
|
|
[520.0, 60.0],
|
|
[560.0, 150.0],
|
|
[300.0, 190.0],
|
|
[60.0, 150.0],
|
|
[300.0, 20.0],
|
|
];
|
|
let cub = [[0.0, 0.0], [0.0, 200.0], [300.0, -100.0], [300.0, 100.0]];
|
|
let pairs = [
|
|
(
|
|
MotionPath::through(&pts, 1.3).unwrap(),
|
|
MotionPath::through3(&flat(&pts), 1.3).unwrap(),
|
|
),
|
|
(
|
|
MotionPath::through(&loop_pts, 1.0).unwrap(),
|
|
MotionPath::through3(&flat(&loop_pts), 1.0).unwrap(),
|
|
),
|
|
(
|
|
MotionPath::polyline(&pts, true).unwrap(),
|
|
MotionPath::polyline3(&flat(&pts), true).unwrap(),
|
|
),
|
|
(
|
|
MotionPath::cubic_points(&cub).unwrap(),
|
|
MotionPath::cubic_points3(&flat(&cub)).unwrap(),
|
|
),
|
|
(
|
|
{
|
|
let mut b = MotionPath::builder();
|
|
b.move_to(1.0, 2.0)
|
|
.line_to(40.0, 7.0)
|
|
.quad_to(60.0, 90.0, 80.0, 10.0)
|
|
.cubic_to(90.0, -20.0, 130.0, 50.0, 150.0, 0.0);
|
|
b.finish().unwrap()
|
|
},
|
|
{
|
|
let mut b = MotionPath::builder();
|
|
b.move_to3(1.0, 2.0, 0.0)
|
|
.line_to3(40.0, 7.0, 0.0)
|
|
.quad_to3(60.0, 90.0, 0.0, 80.0, 10.0, 0.0)
|
|
.cubic_to3([90.0, -20.0, 0.0], [130.0, 50.0, 0.0], [150.0, 0.0, 0.0]);
|
|
b.finish().unwrap()
|
|
},
|
|
),
|
|
];
|
|
for (i, (a, b)) in pairs.iter().enumerate() {
|
|
assert_eq!(a.length().to_bits(), b.length().to_bits(), "pair {i}");
|
|
assert_eq!(a.span_count(), b.span_count(), "pair {i}");
|
|
assert_eq!(a.bounds(), b.bounds(), "pair {i}");
|
|
for k in 0..=97 {
|
|
let u = k as f64 / 97.0 * 1.3 - 0.1;
|
|
assert_eq!(a.sample(u), b.sample(u), "pair {i} u {u}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_lap_adds_whole_turns_also_across_a_corner_seam() {
|
|
let square = svg("M0,0 L10,0 L10,10 L0,10 Z");
|
|
close(square.total_turn(), 270.0, 1e-9, "square turn");
|
|
close(square.lap_turn(), 360.0, 1e-9, "square lap");
|
|
close(
|
|
square.sample(1.125).angle_deg,
|
|
360.0,
|
|
1e-9,
|
|
"second lap, first edge",
|
|
);
|
|
close(square.sample(-0.875).angle_deg, -360.0, 1e-9, "lap before");
|
|
close(circle().lap_turn(), 360.0, 1e-9, "circle lap");
|
|
let s = svg("M20,160 C120,-20 220,220 320,100 S520,0 580,160 A60,60 0 0,1 460,160 L400,120 Q340,60 300,140 T160,160 Z");
|
|
let lap = s.lap_turn();
|
|
close(
|
|
lap - 360.0 * (lap / 360.0).round(),
|
|
0.0,
|
|
1e-9,
|
|
"svg lap is whole turns",
|
|
);
|
|
// The seam corner shows as a step between u = 1 and just past it.
|
|
let (a, b) = (s.sample(1.0), s.sample(1.0 + 1e-12));
|
|
let corner = b.angle_deg - a.angle_deg;
|
|
let want = s.sample(0.0).angle_deg + lap - a.angle_deg;
|
|
close(corner, want, 1e-6, "the corner at the seam");
|
|
}
|
|
|
|
#[test]
|
|
fn an_arc_scaled_to_its_chord_has_its_centre_on_it() {
|
|
// Radii too small for the chord: the half-arc join is exact, not off by
|
|
// the 1e-8 roundoff of the unscaled centre formula.
|
|
let p = svg("M0,0 a1 1 0 00 2 2");
|
|
assert_eq!(p.segment_count(), 2);
|
|
let j = p.segment(0).end();
|
|
close(j[0], 0.0, 1e-12, "join x");
|
|
close(j[1], 2.0, 1e-12, "join y");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path tweens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn wave() -> MotionPath {
|
|
MotionPath::through(
|
|
&[[0.0, 0.0], [100.0, -40.0], [200.0, 40.0], [300.0, 0.0]],
|
|
1.0,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn path_tween_lands_exactly_at_both_ends() {
|
|
let mut e = TweenEngine::new();
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let opts = PathOpts::new().auto_rotate(R, 90.0);
|
|
let id = e.to(
|
|
one(0),
|
|
&[PropTo::path(X, Y, p, opts)],
|
|
TweenOpts::new()
|
|
.duration(1.0)
|
|
.ease(Easing::InOutSine)
|
|
.keep(true),
|
|
);
|
|
e.release_path(p);
|
|
for _ in 0..70 {
|
|
e.advance(1.0 / 60.0);
|
|
}
|
|
let end = g.sample_span(0.0, 1.0, 1.0);
|
|
assert_eq!(val(&e, 0, X).to_bits(), end.pos[0].to_bits());
|
|
assert_eq!(val(&e, 0, Y).to_bits(), end.pos[1].to_bits());
|
|
assert_eq!(val(&e, 0, R).to_bits(), (end.angle_deg + 90.0).to_bits());
|
|
assert_eq!(val(&e, 0, X), 300.0);
|
|
e.anim(id).seek(Seek::Time(0.0), Emit::Suppress);
|
|
let start = g.sample_span(0.0, 1.0, 0.0);
|
|
assert_eq!(val(&e, 0, X).to_bits(), start.pos[0].to_bits());
|
|
assert_eq!(val(&e, 0, Y).to_bits(), start.pos[1].to_bits());
|
|
assert_eq!(val(&e, 0, R).to_bits(), (start.angle_deg + 90.0).to_bits());
|
|
// No z key: no z slot.
|
|
assert!(e.slot(tg(0), Z).is_none());
|
|
// The path is held by the kept tween.
|
|
assert_eq!(e.path_count(), 1);
|
|
assert!(e.path(p).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn overshooting_ease_stays_on_the_path() {
|
|
let mut e = TweenEngine::new();
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let id = e.to(
|
|
one(0),
|
|
&[PropTo::path(X, Y, p, PathOpts::new().span(0.1, 0.9))],
|
|
TweenOpts::new()
|
|
.duration(1.0)
|
|
.ease(Easing::OutBack)
|
|
.keep(true),
|
|
);
|
|
e.release_path(p);
|
|
let end = g.sample_span(0.1, 0.9, 1.0);
|
|
let mut overshot = 0;
|
|
for _ in 0..64 {
|
|
e.advance(1.0 / 60.0);
|
|
let prog = e.anim_ref(id).progress();
|
|
let eased = Easing::OutBack.map(prog);
|
|
let want = g.sample_span(0.1, 0.9, eased);
|
|
close(val(&e, 0, X), want.pos[0], 1e-9, "x on the path");
|
|
close(val(&e, 0, Y), want.pos[1], 1e-9, "y on the path");
|
|
if eased >= 1.0 {
|
|
overshot += 1;
|
|
// The plateau is the end, bitwise.
|
|
assert_eq!(val(&e, 0, X).to_bits(), end.pos[0].to_bits());
|
|
assert_eq!(val(&e, 0, Y).to_bits(), end.pos[1].to_bits());
|
|
}
|
|
}
|
|
assert!(overshot > 5, "{overshot}");
|
|
}
|
|
|
|
#[test]
|
|
fn align_start_moves_the_path_to_the_target() {
|
|
let mut e = TweenEngine::new();
|
|
e.seed(tg(0), X, 500.0.into());
|
|
e.seed(tg(0), Y, 300.0.into());
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let opts = PathOpts::new().align(PathAlign::Start);
|
|
let id = e.to(
|
|
Targets::List(&[tg(0), tg(1)]),
|
|
&[PropTo::path(X, Y, p, opts)],
|
|
lin(1.0).keep(true),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.5);
|
|
let mid = g.sample(0.5);
|
|
close(val(&e, 0, X), 500.0 + mid.pos[0], 1e-9, "mid x");
|
|
close(val(&e, 0, Y), 300.0 + mid.pos[1], 1e-9, "mid y");
|
|
// Target 1 was never seeded: it does not align (and says so).
|
|
assert_eq!(e.stats().unseeded_starts, 2);
|
|
close(val(&e, 1, X), mid.pos[0], 1e-9, "unaligned x");
|
|
e.advance(1.0);
|
|
close(val(&e, 0, X), 800.0, 1e-9, "end x");
|
|
close(val(&e, 0, Y), 300.0, 1e-9, "end y");
|
|
assert_eq!(val(&e, 1, X), 300.0);
|
|
e.anim(id).seek(Seek::Time(0.0), Emit::Suppress);
|
|
assert_eq!(val(&e, 0, X), 500.0);
|
|
assert_eq!(val(&e, 0, Y), 300.0);
|
|
}
|
|
|
|
#[test]
|
|
fn offset_and_radians() {
|
|
let mut e = TweenEngine::new();
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let opts = PathOpts::new()
|
|
.offset(10.0, -5.0)
|
|
.auto_rotate_radians(R, 90.0)
|
|
.span(0.0, 0.5);
|
|
e.to(one(0), &[PropTo::path(X, Y, p, opts)], lin(1.0));
|
|
e.release_path(p);
|
|
e.advance(0.5);
|
|
let q = g.sample_span(0.0, 0.5, 0.5);
|
|
close(val(&e, 0, X), q.pos[0] + 10.0, 1e-9, "x");
|
|
close(val(&e, 0, Y), q.pos[1] - 5.0, 1e-9, "y");
|
|
close(
|
|
val(&e, 0, R),
|
|
(q.angle_deg + 90.0).to_radians(),
|
|
1e-12,
|
|
"radians",
|
|
);
|
|
e.advance(1.0);
|
|
let z = g.sample_span(0.0, 0.5, 1.0);
|
|
close(val(&e, 0, X), z.pos[0] + 10.0, 0.0, "end x");
|
|
close(
|
|
val(&e, 0, R),
|
|
(z.angle_deg + 90.0).to_radians(),
|
|
1e-12,
|
|
"end radians",
|
|
);
|
|
// Tween and path are gone.
|
|
assert_eq!((e.path_count(), e.stats().path_binds), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn overwrite_auto_on_x_leaves_y_on_the_path() {
|
|
let mut e = TweenEngine::new();
|
|
e.seed(tg(0), X, 0.0.into());
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let id = e.to(
|
|
one(0),
|
|
&[PropTo::path(X, Y, p, PathOpts::new().auto_rotate(R, 0.0))],
|
|
lin(1.0).keep(true),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.25);
|
|
e.to(one(0), &[to(X, -50.0)], lin(0.5).overwrite(Overwrite::Auto));
|
|
e.advance(0.25);
|
|
let q = g.sample(0.5);
|
|
close(val(&e, 0, Y), q.pos[1], 1e-9, "y stays on the path");
|
|
close(
|
|
val(&e, 0, R),
|
|
q.angle_deg,
|
|
1e-9,
|
|
"rotation stays on the path",
|
|
);
|
|
assert!(
|
|
val(&e, 0, X) < q.pos[0] - 20.0,
|
|
"x left the path: {}",
|
|
val(&e, 0, X)
|
|
);
|
|
e.advance(1.0);
|
|
assert_eq!(val(&e, 0, X), -50.0);
|
|
assert_eq!(val(&e, 0, Y), 0.0);
|
|
assert!(e.anim_ref(id).is_alive());
|
|
assert_eq!(e.stats().path_binds, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn stagger_binds_each_target() {
|
|
let mut e = TweenEngine::new();
|
|
for i in 0..5 {
|
|
e.seed(tg(i), X, (100.0 * i as f64).into());
|
|
e.seed(tg(i), Y, (10.0 * i as f64).into());
|
|
}
|
|
let p = e.add_path(wave());
|
|
e.to(
|
|
Targets::Range { first: 0, count: 5 },
|
|
&[PropTo::path(
|
|
X,
|
|
Y,
|
|
p,
|
|
PathOpts::new().align(PathAlign::Start),
|
|
)],
|
|
lin(0.5).stagger(Stagger::each(0.1)),
|
|
);
|
|
e.release_path(p);
|
|
assert_eq!(e.stats().path_binds, 5);
|
|
assert_eq!(e.path_count(), 1);
|
|
for _ in 0..60 {
|
|
e.advance(1.0 / 60.0);
|
|
}
|
|
for i in 0..5 {
|
|
close(val(&e, i, X), 100.0 * i as f64 + 300.0, 1e-9, "x");
|
|
close(val(&e, i, Y), 10.0 * i as f64, 1e-9, "y");
|
|
}
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn percent_keyframes_carry_a_path_on_the_group() {
|
|
let mut e = TweenEngine::new();
|
|
e.seed(tg(0), K, 0.0.into());
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
let keys = [
|
|
Key {
|
|
at: 50.0,
|
|
value: 10.0.into(),
|
|
ease: None,
|
|
},
|
|
Key {
|
|
at: 100.0,
|
|
value: 0.0.into(),
|
|
ease: None,
|
|
},
|
|
];
|
|
e.tween(
|
|
one(0),
|
|
&[
|
|
PropTo::keys(K, &keys),
|
|
PropTo::path(X, Y, p, PathOpts::new()),
|
|
],
|
|
TweenOpts::new().duration(1.0),
|
|
);
|
|
e.release_path(p);
|
|
assert_eq!(e.stats().path_binds, 1);
|
|
e.advance(0.25);
|
|
// The group's outer ease (linear by default) eases the path.
|
|
let q = g.sample(0.25);
|
|
close(val(&e, 0, X), q.pos[0], 1e-9, "x at 0.25");
|
|
close(val(&e, 0, Y), q.pos[1], 1e-9, "y at 0.25");
|
|
e.advance(1.0);
|
|
assert_eq!(val(&e, 0, X), 300.0);
|
|
assert_eq!(val(&e, 0, K), 0.0);
|
|
assert_eq!(e.path_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn sample_matches_render_for_path_tracks() {
|
|
let mut e = TweenEngine::new();
|
|
let p = e.add_path(wave());
|
|
let id = e.to(
|
|
one(0),
|
|
&[PropTo::path(
|
|
X,
|
|
Y,
|
|
p,
|
|
PathOpts::new().auto_rotate(R, 45.0).offset(3.0, 4.0),
|
|
)],
|
|
TweenOpts::new()
|
|
.duration(1.0)
|
|
.ease(Easing::OutQuad)
|
|
.keep(true),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.01);
|
|
let slots = [X, Y, R].map(|k| e.slot(tg(0), k).unwrap());
|
|
for t in [0.25, 0.5, 0.8, 1.0] {
|
|
let want: Vec<_> = slots.iter().map(|s| e.sample(id, *s, t).unwrap()).collect();
|
|
e.anim(id).seek(Seek::Time(t), Emit::Suppress);
|
|
for (k, s) in slots.iter().enumerate() {
|
|
assert_eq!(e.value(*s), want[k], "t {t} lane {k}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn path_lifecycle() {
|
|
let mut e = TweenEngine::new();
|
|
let g = wave();
|
|
let p = e.add_path(g.clone());
|
|
assert_eq!(e.path_count(), 1);
|
|
assert_eq!(e.path(p).unwrap().length(), g.length());
|
|
e.to(one(0), &[PropTo::path(X, Y, p, PathOpts::new())], lin(0.5));
|
|
e.release_path(p);
|
|
e.release_path(p); // a second release is a no-op
|
|
assert_eq!(e.path_count(), 1);
|
|
e.advance(0.25);
|
|
assert!(e.path(p).is_some());
|
|
e.advance(0.5); // completes and is reaped: the bind and the path go
|
|
assert_eq!(e.path_count(), 0);
|
|
assert!(e.path(p).is_none());
|
|
assert_eq!(e.stats().paths, 0);
|
|
// The slot is reused; the old handle stays stale.
|
|
let q = e.add_path(svg("M0,0 L10,0"));
|
|
assert_ne!(p, q);
|
|
assert!(e.path(p).is_none());
|
|
assert_eq!(e.path(q).unwrap().length(), 10.0);
|
|
// A stale handle builds no track.
|
|
let tracks = e.stats().tracks;
|
|
e.to(one(1), &[PropTo::path(X, Y, p, PathOpts::new())], lin(0.5));
|
|
assert_eq!(e.stats().bad_paths, 1);
|
|
assert_eq!(e.stats().tracks, tracks);
|
|
assert!(e.slot(tg(1), X).is_none());
|
|
// An unused path is freed by its release at once.
|
|
e.release_path(q);
|
|
assert_eq!(e.path_count(), 0);
|
|
assert!(e.path(q).is_none());
|
|
assert!(PathId::NONE.is_none() && PathId::default().is_none());
|
|
assert!(e.path(PathId::NONE).is_none());
|
|
// Killing a kept tween frees its path.
|
|
let r = e.add_path(wave());
|
|
let id = e.to(
|
|
one(2),
|
|
&[PropTo::path(X, Y, r, PathOpts::new())],
|
|
lin(1.0).keep(true),
|
|
);
|
|
e.release_path(r);
|
|
e.advance(2.0);
|
|
assert_eq!(e.path_count(), 1);
|
|
e.anim(id).kill();
|
|
assert_eq!(e.path_count(), 0);
|
|
// forget_target kills the tween and frees the path too.
|
|
let r = e.add_path(wave());
|
|
e.to(one(3), &[PropTo::path(X, Y, r, PathOpts::new())], lin(1.0));
|
|
e.release_path(r);
|
|
e.advance(0.1);
|
|
e.forget_target(tg(3));
|
|
assert_eq!((e.path_count(), e.stats().path_binds), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn repeat_refresh_realigns_each_iteration() {
|
|
let mut e = TweenEngine::new();
|
|
e.seed(tg(0), X, 0.0.into());
|
|
e.seed(tg(0), Y, 0.0.into());
|
|
let p = e.add_path(wave());
|
|
e.to(
|
|
one(0),
|
|
&[PropTo::path(
|
|
X,
|
|
Y,
|
|
p,
|
|
PathOpts::new().align(PathAlign::Start),
|
|
)],
|
|
lin(1.0).repeat(2).repeat_refresh(true),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.5);
|
|
e.advance(1.0);
|
|
close(
|
|
val(&e, 0, X),
|
|
300.0 + 150.0,
|
|
1e-6,
|
|
"second lap starts where the first ended",
|
|
);
|
|
for _ in 0..120 {
|
|
e.advance(1.0 / 60.0);
|
|
}
|
|
close(val(&e, 0, X), 900.0, 1e-9, "three laps");
|
|
close(val(&e, 0, Y), 0.0, 1e-9, "y");
|
|
}
|
|
|
|
#[test]
|
|
fn a_3d_path_writes_z_and_snap_rounds_the_positions_not_the_rotation() {
|
|
let mut e = TweenEngine::new();
|
|
let p =
|
|
e.add_path(MotionPath::polyline3(&[[0.0, 0.0, 0.0], [10.0, 20.0, 30.0]], false).unwrap());
|
|
e.to(
|
|
one(0),
|
|
&[PropTo::path(
|
|
X,
|
|
Y,
|
|
p,
|
|
PathOpts::new().z(Z).offset3(0.0, 0.0, 1.0),
|
|
)],
|
|
lin(1.0),
|
|
);
|
|
e.to(
|
|
one(1),
|
|
&[PropTo::path(X, Y, p, PathOpts::new().z(Z).auto_rotate(R, 0.3)).snap(1.0)],
|
|
lin(1.0),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.5);
|
|
close(val(&e, 0, X), 5.0, 1e-12, "x");
|
|
close(val(&e, 0, Y), 10.0, 1e-12, "y");
|
|
close(val(&e, 0, Z), 16.0, 1e-12, "z");
|
|
e.advance(0.21);
|
|
assert_eq!(val(&e, 1, X), 7.0);
|
|
assert_eq!(val(&e, 1, Y), 14.0);
|
|
assert_eq!(val(&e, 1, Z), 21.0);
|
|
// The rotation is not snapped: atan2(20, 10) = 63.43 deg, + 0.3.
|
|
close(
|
|
val(&e, 1, R),
|
|
20f64.atan2(10.0).to_degrees() + 0.3,
|
|
1e-9,
|
|
"rotation",
|
|
);
|
|
e.advance(1.0);
|
|
assert_eq!(val(&e, 0, Z), 31.0);
|
|
assert_eq!(e.path_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bind_goes_with_its_last_live_track() {
|
|
let mut e = TweenEngine::new();
|
|
for i in 0..2 {
|
|
e.seed(tg(i), K, 0.0.into());
|
|
}
|
|
let p = e.add_path(wave());
|
|
let id = e.to(
|
|
Targets::List(&[tg(0), tg(1)]),
|
|
&[
|
|
PropTo::path(X, Y, p, PathOpts::new().auto_rotate(R, 0.0)),
|
|
to(K, 1.0),
|
|
],
|
|
lin(1.0).keep(true),
|
|
);
|
|
e.release_path(p);
|
|
e.advance(0.25);
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (2, 1));
|
|
// x and y of target 0 die: its rotation still follows the path.
|
|
e.kill_tweens_of(one(0), Some(&[X, Y]));
|
|
assert_eq!(e.stats().path_binds, 2);
|
|
e.kill_tweens_of(one(0), Some(&[R]));
|
|
assert_eq!(e.stats().path_binds, 1);
|
|
// Target 1's lanes go: the path goes with them, the tween (K) stays.
|
|
e.kill_tweens_of(one(1), Some(&[X, Y, R]));
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (0, 0));
|
|
assert!(e.path(p).is_none());
|
|
assert!(e.anim_ref(id).is_alive());
|
|
e.advance(1.0);
|
|
assert_eq!(val(&e, 1, K), 1.0);
|
|
// Killing the node afterwards releases nothing twice.
|
|
e.anim(id).kill();
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (0, 0));
|
|
// The freed bind slot is reused by the next path tween.
|
|
let q = e.add_path(wave());
|
|
e.to(one(2), &[PropTo::path(X, Y, q, PathOpts::new())], lin(0.5));
|
|
e.release_path(q);
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (1, 1));
|
|
e.advance(1.0);
|
|
assert_eq!((e.stats().path_binds, e.path_count()), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn set_jumps_to_the_path_end() {
|
|
let mut e = TweenEngine::new();
|
|
let p = e.add_path(wave());
|
|
e.set(
|
|
one(0),
|
|
&[PropTo::path(X, Y, p, PathOpts::new().span(0.0, 0.5))],
|
|
TweenOpts::new(),
|
|
);
|
|
e.release_path(p);
|
|
let q = wave().sample(0.5);
|
|
assert_eq!(val(&e, 0, X), q.pos[0]);
|
|
assert_eq!(val(&e, 0, Y), q.pos[1]);
|
|
assert_eq!(e.path_count(), 0);
|
|
}
|