From f9dddfeb1010a4261e5f3a064efece431f02c18b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 08:53:04 +0200 Subject: [PATCH] fix(graph): speed graph Fit Selection frames the curve between the keys (fixes #1617) --- crates/ui-egui/src/panels/graph.rs | 46 ++++++++++++++++++++-------- crates/ui-egui/tests/ui_keyframes.rs | 36 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index fc6d40e8..0b372c0a 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -109,6 +109,15 @@ fn shown_dims(l: &Layer, pr: &Property) -> usize { if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) } } +/// The speed graph's value of dimension `d` at layer time `lt` (one curve for a spatial property). +fn speed_at(pr: &Property, spatial: bool, d: usize, lt: Tick) -> f64 { + if spatial { + effectcraft_engine::keyframe::speed(&pr.keys, lt, true) + } else { + effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs() + } +} + /// Build the curves for the visible time span. fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec> { let sel = &app.session.state.selected_keys; @@ -126,16 +135,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32; let ct = tm.t(x); let tt = Tick::from_seconds_f64(ct); - let v = if speed { - let lt = l.layer_time(tt); - if spatial { - effectcraft_engine::keyframe::speed(&pr.keys, lt, true) - } else { - effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs() - } - } else { - ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0) - }; + let v = if speed { speed_at(pr, spatial, d, l.layer_time(tt)) } else { ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0) }; pts.push((ct, v)); } let mut keys = vec![]; @@ -290,7 +290,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ctx.data_mut(|d| d.insert_temp(was_speed_id, speed)); if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed { if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) { - fit_selection(app, &cs, tm, plot); + fit_selection(app, &cs, tm, plot, speed); } else { fit_all(app); } @@ -584,7 +584,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte fit_all(app); } if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { - fit_selection(app, &cs, tm, plot); + fit_selection(app, &cs, tm, plot, speed); } buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); @@ -646,7 +646,7 @@ pub(crate) fn track_open(ctx: &egui::Context, on: bool) { } /// Zoom time and value to the selected keys (and their handles). -fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { +fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) { let mut t0 = f64::INFINITY; let mut t1 = f64::NEG_INFINITY; let mut v0 = f64::INFINITY; @@ -678,6 +678,26 @@ fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { } } } + // In the speed graph eased keys sit at speed 0 and the curve peaks between them (#1617): + // frame the curve from the first to the last selected key. A lone key has its handles on + // the speed graph, so the check above misses it: it frames its neighbouring segments here. + if speed { + for c in cs { + let (Some(first), Some(last)) = (c.keys.iter().position(|k| k.sel), c.keys.iter().rposition(|k| k.sel)) else { continue }; + let (a, b) = if first == last { (first.saturating_sub(1), last + 1) } else { (first, last) }; + let (Some(ka), Some(kb)) = (c.keys.get(a), c.keys.get(b.min(c.keys.len().saturating_sub(1)))) else { continue }; + t0 = t0.min(ka.t); + t1 = t1.max(kb.t); + let (s0, s1) = (ka.time.seconds(), kb.time.seconds()); + const SAMPLES: usize = 128; + for i in 0..=SAMPLES { + let lt = Tick::from_seconds_f64(s0 + (s1 - s0) * i as f64 / SAMPLES as f64); + let v = speed_at(c.prop, !c.editable, c.dim, lt); + v0 = v0.min(v); + v1 = v1.max(v); + } + } + } let span = (t1 - t0).max(0.2); let pps = plot.width() as f64 * 0.8 / span; // Through the timeline's zoom, so the fit never zooms out past the whole comp. diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 4b5d1338..cbe0b387 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -466,3 +466,39 @@ fn speed_graph_handle_drags_upward_past_the_top() { // The speed per pixel depends on the graph's fitted scale; only the proportion matters. assert!(short > 0.0 && long > 9.0 * short, "40 px up gave {short}, 400 px up gave {long}"); } + +/// #1617: with Easy Ease both keys sit at speed 0 in the speed graph, so fitting the selection to +/// its keys and handles framed -1..1 while the curve between them peaks far above. Opening the +/// Graph Editor on selected keys and Fit Selection both frame the curve's peak, and a lone +/// selected key frames its neighbouring segments. +#[test] +fn speed_graph_fit_selection_frames_the_curve_between_the_keys() { + let (mut h, id, uid) = harness(); + let s = &mut h.state_mut().session; + s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap(); + s.execute("keys.selectAll", json!({})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let keys = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.clone(); + let peak = (0..=100) + .map(|i| effectcraft_engine::keyframe::velocity(&keys, effectcraft_engine::time::Tick::from_seconds_f64(i as f64 / 100.0), false)[0].abs()) + .fold(0.0, f64::max); + assert!(peak > 100.0, "the eased curve peaks above its average speed: {peak}"); + h.state_mut().ui.timeline.graph_mode = "speed".into(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let frames_peak = |h: &Harness<'_, EffectcraftApp>, what: &str| { + let (lo, hi) = h.state().ui.timeline.graph_range.expect("a fitted range"); + assert!(lo <= 0.0 && hi > peak * 0.9, "{what}: range {lo}..{hi}, the curve peaks at {peak}"); + }; + frames_peak(&h, "on open"); + h.state_mut().ui.timeline.graph_range = Some((-1.0, 1.0)); + let fit = h.state().auto.find("timeline.graph.fitSelection").expect("the Fit Selection button").rect; + click_with(&mut h, pos2(fit[0] + fit[2] / 2.0, fit[1] + fit[3] / 2.0), Default::default()); + frames_peak(&h, "Fit Selection"); + let last = keys[1].time.seconds(); + h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": id.0, "prop": uid, "time": last}]})).unwrap(); + assert_eq!(h.state().session.state.selected_keys.len(), 1); + h.state_mut().ui.timeline.graph_range = Some((-1.0, 1.0)); + click_with(&mut h, pos2(fit[0] + fit[2] / 2.0, fit[1] + fit[3] / 2.0), Default::default()); + frames_peak(&h, "lone key"); +}