M5.8: motion paths + graph transform box
Graph Editor (ANM-4) and timeline keyframe tools: - Transform box around two or more selected keys: corners scale time and value about the opposite corner, edges one axis, the inside moves the keys (keys.transform, one undo step per drag, times land on frames). - Snap: dragged key times snap to the current time and other keys. - Show Reference Graph: the other graph type, faint, behind the curves. - Timeline Alt-drag of a key group's first or last key scales the group in time about the other end. - keys.transform takes dims (only the shown dimensions change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
468dc13cfe
commit
f455b8b2fb
|
|
@ -231,11 +231,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
|
||||
// Show Reference Graph: the other graph type, faint, scaled to the plot.
|
||||
if app.ui.timeline.graph_reference && !cs.is_empty() {
|
||||
let other = curves(app, comp, ectx, tm, plot, !speed);
|
||||
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for c in &other {
|
||||
for (_, v) in &c.pts {
|
||||
rlo = rlo.min(*v);
|
||||
rhi = rhi.max(*v);
|
||||
}
|
||||
}
|
||||
if rlo.is_finite() {
|
||||
let span = (rhi - rlo).max(1e-9);
|
||||
let lines: Vec<(Color32, Vec<Pos2>)> = other
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.color,
|
||||
c.pts
|
||||
.iter()
|
||||
.map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::graph_tools::reference_graph(&pc, plot, &lines);
|
||||
}
|
||||
}
|
||||
// Curves.
|
||||
for c in &cs {
|
||||
let pts: Vec<Pos2> = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect();
|
||||
pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color)));
|
||||
}
|
||||
// Transform box around several selected keys (inside below the keys, handles above).
|
||||
let sel_keys: Vec<super::graph_tools::SelKey> = cs
|
||||
.iter()
|
||||
.flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable }))
|
||||
.collect();
|
||||
let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect();
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
|
||||
// Keys and handles.
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
let fr = comp.frame_rate;
|
||||
|
|
@ -298,7 +332,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))));
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct)));
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge});
|
||||
if !speed && c.editable {
|
||||
let mut comps = c.prop.keys[k.idx].value.components();
|
||||
|
|
@ -316,6 +353,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
}
|
||||
}
|
||||
|
||||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
|
||||
|
||||
// Background: click deselects keys, drag box-selects.
|
||||
if bg.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
|
|
@ -374,6 +413,17 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
fit_selection(app, &cs, tm, plot);
|
||||
}
|
||||
x += 6.0;
|
||||
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
|
||||
if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
|
||||
app.ui.timeline.graph_snap = !snap;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") {
|
||||
app.ui.timeline.graph_reference = !reference;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
|
||||
app.ui.timeline.graph_transform_box = !tbox;
|
||||
}
|
||||
x += 6.0;
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
|
||||
let layer = comp.layer(*l)?;
|
||||
let tr = layer.transform()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
//! Graph Editor and timeline keyframe tools: the transform box around several selected keys
|
||||
//! (scale and move them in time and value), key-time snapping, and Alt-drag of a key group's
|
||||
//! first or last key in the timeline to scale the group in time. Edits are `keys.transform`.
|
||||
|
||||
use effectcraft_engine::project::Comp;
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
/// A selected key point in the graph: comp seconds, graph value, the dimension it shows, and
|
||||
/// whether its value is editable there.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SelKey {
|
||||
pub t: f64,
|
||||
pub v: f64,
|
||||
pub dim: usize,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// Transform box geometry (data space): [t0, v0, t1, v1].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct BoxDrag {
|
||||
/// 0–3 corners (t0v1, t1v1, t1v0, t0v0 = screen TL, TR, BR, BL), 4–7 edges (top, right,
|
||||
/// bottom, left), 8 = move.
|
||||
handle: usize,
|
||||
/// Fixed opposite point (comp s, value).
|
||||
anchor: [f64; 2],
|
||||
/// Pointer data position of the previous frame.
|
||||
last: [f64; 2],
|
||||
}
|
||||
|
||||
fn box_id() -> egui::Id {
|
||||
egui::Id::new("graph-transform-box")
|
||||
}
|
||||
|
||||
/// Bounds of the selected keys: (t0, v0, t1, v1).
|
||||
fn bounds(keys: &[SelKey]) -> Option<[f64; 4]> {
|
||||
if keys.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for k in keys {
|
||||
b = [b[0].min(k.t), b[1].min(k.v), b[2].max(k.t), b[3].max(k.v)];
|
||||
}
|
||||
(b[2] > b[0] || b[3] > b[1]).then_some(b)
|
||||
}
|
||||
|
||||
fn handle_points(r: Rect) -> [Pos2; 8] {
|
||||
let c = r.center();
|
||||
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), pos2(c.x, r.min.y), pos2(r.max.x, c.y), pos2(c.x, r.max.y), pos2(r.min.x, c.y)]
|
||||
}
|
||||
|
||||
/// The transform box for two or more selected keys. Call before the keys are drawn (its inside
|
||||
/// sits below them) with `handles = false`, and after them with `handles = true` (the handles sit
|
||||
/// on top). Dragging a corner scales time and value about the opposite corner, an edge one axis,
|
||||
/// the inside moves the keys; times land on frames.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn transform_box(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
keys: &[SelKey],
|
||||
tm: TMap,
|
||||
ymap: &dyn Fn(f64) -> f32,
|
||||
vmap: &dyn Fn(f32) -> f64,
|
||||
speed: bool,
|
||||
handles: bool,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
if !app.ui.timeline.graph_transform_box {
|
||||
return;
|
||||
}
|
||||
let Some(b) = bounds(keys) else { return };
|
||||
let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0);
|
||||
let value_ok = !speed && keys.iter().all(|k| k.editable);
|
||||
let col = Color32::from_rgb(0xd8, 0xd8, 0xd8);
|
||||
let ctx = ui.ctx().clone();
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let hp = handle_points(r);
|
||||
let data = |q: Pos2| [tm.t(q.x), vmap(q.y)];
|
||||
// Which data point each handle sits on, and its opposite.
|
||||
let corner = |i: usize| -> [f64; 2] {
|
||||
match i {
|
||||
0 => [b[0], b[3]],
|
||||
1 => [b[2], b[3]],
|
||||
2 => [b[2], b[1]],
|
||||
3 => [b[0], b[1]],
|
||||
4 => [(b[0] + b[2]) / 2.0, b[3]],
|
||||
5 => [b[2], (b[1] + b[3]) / 2.0],
|
||||
6 => [(b[0] + b[2]) / 2.0, b[1]],
|
||||
_ => [b[0], (b[1] + b[3]) / 2.0],
|
||||
}
|
||||
};
|
||||
let mut start: Option<BoxDrag> = None;
|
||||
let mut resp_any: Option<egui::Response> = None;
|
||||
if !handles {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle);
|
||||
let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag());
|
||||
app.auto.add("timeline.graph.transformBox", r, "Transform box");
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
start = Some(BoxDrag { handle: 8, anchor: [0.0; 2], last: data(o) });
|
||||
}
|
||||
resp_any = Some(resp);
|
||||
} else {
|
||||
for (i, h) in hp.iter().enumerate() {
|
||||
// Value handles only when values can change.
|
||||
if !value_ok && matches!(i, 4 | 6) {
|
||||
continue;
|
||||
}
|
||||
let hr = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
p.rect_filled(hr, 0.0, Color32::from_gray(0x20));
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle);
|
||||
let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle");
|
||||
if resp.drag_started() {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
start = Some(BoxDrag { handle: i, anchor: corner(opp), last: corner(i) });
|
||||
}
|
||||
if resp.dragged() || resp.drag_stopped() {
|
||||
resp_any = Some(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(box_id(), s);
|
||||
d.insert_temp(drag_id, true);
|
||||
});
|
||||
}
|
||||
let Some(resp) = resp_any else { return };
|
||||
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
if (st.handle == 8) == handles {
|
||||
return;
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cur = data(pt);
|
||||
let mut params = json!({"merge": "graph-box"});
|
||||
if st.handle == 8 {
|
||||
params["timeOffset"] = json!(cur[0] - st.last[0]);
|
||||
if value_ok {
|
||||
params["valueOffset"] = json!(cur[1] - st.last[1]);
|
||||
}
|
||||
st.last = cur;
|
||||
} else {
|
||||
let (time_axis, value_axis) = match st.handle {
|
||||
4 | 6 => (false, true),
|
||||
5 | 7 => (true, false),
|
||||
_ => (true, true),
|
||||
};
|
||||
// Scale relative to where the handle is now (incremental, so merged steps compose).
|
||||
let now = corner(st.handle);
|
||||
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
|
||||
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
|
||||
if k > 0.01 {
|
||||
params["timeScale"] = json!(k);
|
||||
params["timeAnchor"] = json!(st.anchor[0]);
|
||||
}
|
||||
}
|
||||
if value_axis && value_ok && (now[1] - st.anchor[1]).abs() > 1e-9 {
|
||||
params["valueScale"] = json!((cur[1] - st.anchor[1]) / (now[1] - st.anchor[1]));
|
||||
params["valueAnchor"] = json!(st.anchor[1]);
|
||||
}
|
||||
}
|
||||
let mut dims: Vec<usize> = keys.iter().map(|k| k.dim).collect();
|
||||
dims.sort_unstable();
|
||||
dims.dedup();
|
||||
params["dims"] = json!(dims);
|
||||
if params.as_object().is_some_and(|m| m.len() > 2) {
|
||||
actions.push(("keys.transform".into(), params));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(box_id(), st));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<BoxDrag>(box_id());
|
||||
d.insert_temp(drag_id, false);
|
||||
});
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
|
||||
/// time within 6 points.
|
||||
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
|
||||
if !app.ui.timeline.graph_snap {
|
||||
return t;
|
||||
}
|
||||
let cti = app.session.time().seconds();
|
||||
let x = tm.x(t);
|
||||
std::iter::once(cti)
|
||||
.chain(others.iter().copied())
|
||||
.map(|c| (c, (tm.x(c) - x).abs()))
|
||||
.filter(|(_, d)| *d < 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline Alt-drag scaling
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AltScale {
|
||||
/// The fixed end of the group (comp s).
|
||||
anchor: f64,
|
||||
/// The dragged end is the group's last key (else its first).
|
||||
end_is_max: bool,
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
egui::Id::new("timeline-alt-scale")
|
||||
}
|
||||
|
||||
/// Selected keys' comp-time range.
|
||||
fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> {
|
||||
let ts: Vec<f64> = app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
if ts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let lo = ts.iter().copied().fold(f64::MAX, f64::min);
|
||||
let hi = ts.iter().copied().fold(f64::MIN, f64::max);
|
||||
(hi - lo > 1e-9).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// Alt-drag on the first or last key of a selected group starts scaling the group in time
|
||||
/// about the other end. Returns true when it took the drag.
|
||||
pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool {
|
||||
let Some((lo, hi)) = sel_range(app, comp) else { return false };
|
||||
let st = if (key_ct - hi).abs() < 1e-6 {
|
||||
AltScale { anchor: lo, end_is_max: true }
|
||||
} else if (key_ct - lo).abs() < 1e-6 {
|
||||
AltScale { anchor: hi, end_is_max: false }
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), st));
|
||||
true
|
||||
}
|
||||
|
||||
/// While an Alt-drag scale is active: scale the group so its dragged end follows the pointer.
|
||||
pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, tm: TMap, actions: &mut Actions) {
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<AltScale>(alt_id())) else { return };
|
||||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<AltScale>(alt_id()));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
return;
|
||||
}
|
||||
let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
|
||||
let fr = comp.frame_rate;
|
||||
let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds();
|
||||
let end = if st.end_is_max { hi } else { lo };
|
||||
let span = end - st.anchor;
|
||||
let want = target - st.anchor;
|
||||
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
|
||||
return;
|
||||
}
|
||||
let k = want / span;
|
||||
if (k - 1.0).abs() > 1e-6 {
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
|
||||
}
|
||||
}
|
||||
|
||||
/// An Alt-drag group scale is in progress (the plain key move stands aside).
|
||||
pub(crate) fn alt_scale_active(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<AltScale>(alt_id())).is_some()
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Show Reference Graph: the other graph type, normalized to the plot, faint.
|
||||
pub(crate) fn reference_graph(p: &egui::Painter, plot: Rect, curves: &[(Color32, Vec<Pos2>)]) {
|
||||
for (c, pts) in curves {
|
||||
p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35))));
|
||||
}
|
||||
let _ = plot;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ pub mod effects_presets;
|
|||
pub mod forms;
|
||||
pub mod fx_widgets;
|
||||
pub mod graph;
|
||||
pub mod graph_tools;
|
||||
pub mod info;
|
||||
pub mod key_dialogs;
|
||||
pub mod layer_panel;
|
||||
|
|
|
|||
|
|
@ -1320,7 +1320,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if kresp.dragged() {
|
||||
// Alt-drag the first or last key of a selected group: scale the group in time.
|
||||
if kresp.drag_started()
|
||||
&& ui.input(|i| i.modifiers.alt)
|
||||
&& let Some(c) = app.session.active_comp().cloned()
|
||||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
|
|
@ -1364,6 +1372,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Alt-drag key group scaling (follows the pointer until release).
|
||||
if let Some(c) = app.session.active_comp().cloned() {
|
||||
super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
|
||||
}
|
||||
// Graph editor.
|
||||
if graph_on {
|
||||
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
|
||||
|
|
|
|||
|
|
@ -222,3 +222,76 @@ fn motion_path_key_drag_edits_that_key() {
|
|||
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
|
||||
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_transform_box_scales_selected_keys_in_time() {
|
||||
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();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
|
||||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
|
||||
assert!(h.state().auto.find(id).is_some(), "missing {id}");
|
||||
}
|
||||
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
|
||||
let r = rect(&h, "timeline.graph.transformBox.5");
|
||||
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
|
||||
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
|
||||
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
|
||||
drag(&mut h, r.center(), to);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
|
||||
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
|
||||
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
|
||||
assert_eq!(undo, 1, "one undo step per drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_alt_drag_scales_a_key_group_in_time() {
|
||||
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();
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
assert_eq!(ks.len(), 3, "{ks:?}");
|
||||
let (k0, k2) = (ks[0], ks[2]);
|
||||
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
|
||||
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
h.input_mut().events.push(Event::PointerMoved(k2));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(alt));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
|
||||
h.run_steps(2);
|
||||
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
|
||||
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||||
assert_eq!(ts.len(), 3);
|
||||
assert_eq!(ts[0], 0.0);
|
||||
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue