From ed5ad56eb58bdb6c693621e0b8d2c4461fcc5b3c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 16:43:35 +0700 Subject: [PATCH] Viewer: a straight motion path has Bezier handles to curve it (#290) The Position motion path drew a key's spatial handles only where its tangent wasn't zero, and auto-Bezier keys have none at the path's ends, so a two-key path (the usual start) had no handles to pull it into a curve. Auto-Bezier keys now show a handle on such a side too, pointing where the path leaves the key, a third of the way to the next key; dragging it makes the key Bezier with that tangent. Linear keys still show none. Keyframe Interpolation > Spatial Bezier copied those zero tangents, so the keys stayed straight with no handles (and read back as Linear); it now gives them the same handles. The path keeps its shape and timing. The handles come from the new keyframe::spatial_handles. Tests: spatial_bezier_gives_a_straight_path_handles (engine), a_straight_motion_path_has_handles_to_curve_it (ui_viewer). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/prop.rs | 3 +- crates/engine/src/tests_timeline.rs | 16 ++++++++++ crates/keyframe/src/lib.rs | 31 ++++++++++++++++++++ crates/ui-egui/src/panels/viewer_overlays.rs | 8 ++++- crates/ui-egui/tests/ui_viewer.rs | 28 ++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index d50c43532..8ee546dee 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -589,7 +589,8 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { let roving = b_p(p, "roving"); edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| { if let Some(sp) = spatial.as_deref() { - let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i); + // (Bezier keeps the current handles, with handles where the path left straight.) + let (tin, tout) = effectcraft_keyframe::spatial_handles(keys, i); let k = &mut keys[i]; match sp { "linear" => { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 29a440ce4..a392a4780 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -163,6 +163,22 @@ fn velocity_dialog_values_per_dimension_and_continuous() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); } +/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it +/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290). +#[test] +fn spatial_bezier_gives_a_straight_path_handles() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]); + let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); + select_prop_keys(&mut s, l, "transform/position"); + s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| !k.spatial_auto)); + assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0])); + let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); + assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}"); +} + #[test] fn interpolation_dialog_temporal_spatial_and_roving() { let (mut s, l) = setup(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 9a71611ec..584b40702 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -299,6 +299,37 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } +/// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that +/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing +/// where the path leaves the key, toward the segment's next control point, a third of the way to +/// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve. +pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { + let (mut tin, mut tout) = spatial_tangents(keys, i); + let Some(p) = keys.get(i).map(|k| v3(&k.value)) else { return (tin, tout) }; + // Toward key `j`'s control point on this segment (its in side when `j` follows `i`). + let toward = |j: usize, after: bool| -> Option<[f64; 3]> { + let q = v3(&keys.get(j)?.value); + let (jin, jout) = spatial_tangents(keys, j); + let t = if after { jin } else { jout }; + let d = [q[0] + t[0] - p[0], q[1] + t[1] - p[1], q[2] + t[2] - p[2]]; + let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + let len = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt() / 3.0; + (dl > 1e-9).then(|| d.map(|v| v * len / dl)) + }; + let none = |t: [f64; 3]| t == [0.0; 3]; + if none(tout) + && let Some(v) = toward(i + 1, true) + { + tout = v; + } + if none(tin) + && let Some(v) = i.checked_sub(1).and_then(|j| toward(j, false)) + { + tin = v; + } + (tin, tout) +} + /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { let k = &keys[i]; diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 4d3bd312c..51a7d1e59 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -292,7 +292,13 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map } let v = k.value.as_vec3(); let s = to_scr(v); - let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i); + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; for (out, tg) in [(false, tin), (true, tout)] { let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); if !has || (tg[0] == 0.0 && tg[1] == 0.0) { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index a3305a433..f637b56fc 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -479,6 +479,34 @@ fn motion_path_key_drag_edits_that_key() { assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]); } +/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging +/// one pulls the path into a curve (#290). +#[test] +fn a_straight_motion_path_has_handles_to_curve_it() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [400, 100, 0]})).unwrap(); + s.execute("view.snapping", json!({"value": false})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + h.run_steps(3); + let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0)); + assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle"); + // The first key's handle lies a third of the way along the path: drag it down 150 px. + let from = rect(&h, &out0).center(); + assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}"); + let to = screen(&h, [200.0, 250.0]); + drag(&mut h, from, to); + let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone(); + assert!(!p.keys[0].spatial_auto); + let t = p.keys[0].spatial_out; + assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); + let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3(); + assert!(mid[1] > 130.0, "the path curves: {mid:?}"); +} + #[test] fn graph_editor_transform_box_scales_selected_keys_in_time() { let mut h = harness();