diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index d50c4353..8ee546de 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 29a440ce..a392a478 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 9a71611e..584b4070 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 4d3bd312..51a7d1e5 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 a3305a43..f637b56f 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();