Merge pull request #2255 from MarekSpodBiedry/issue-2242

Viewer: motion path follows X/Y Position when dimensions are separated (#2242)
This commit is contained in:
BFlat
2026-10-30 11:57:57 +07:00
committed by GitHub
3 changed files with 132 additions and 31 deletions
+2 -1
View File
@@ -1674,8 +1674,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
} else if let Some(k) = ov::motion_key_at(&key_hits, pos) {
// A key showing direction handles becomes Linear; one without, Auto Bezier.
// Separated X / Y Position have no spatial interpolation to convert.
let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time);
if select_motion_key(app, &k) {
if k.axes.is_none() && select_motion_key(app, &k) {
let spatial = if curved { "linear" } else { "autoBezier" };
let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial}));
}
+8 -4
View File
@@ -137,14 +137,18 @@ pub(super) fn draw_rigs(
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
/// Add a Position keyframe at a motion path's frame, keeping the path where it is.
pub(super) fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
// Separated dimensions: the key goes on X and Y Position.
for prop in f.axes.map_or_else(|| vec![f.prop], Vec::from) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
}
}
}
/// Select only the motion path's keyframe `k` (what the vertex tools act on).
/// Select only the motion path's keyframe `k` (what the vertex tools act on; with separated
/// dimensions, the X and Y Position keys at its time).
pub(super) fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool {
app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok()
app.session.execute("keys.select", json!({"keys": k.select_entries()})).is_ok()
}
/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path
+122 -26
View File
@@ -7,7 +7,7 @@
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::ShapePath;
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::{KeyRef, VertexRef};
@@ -309,6 +309,23 @@ pub(crate) struct KeyHit {
pub value: [f64; 3],
/// Parent space → comp.
pub p2c: Mat3,
/// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`.
pub axes: Option<[(u64, bool); 2]>,
}
impl KeyHit {
/// The keys this dot stands for: Position's, or the X and Y Position keys at its time.
pub(crate) fn key_refs(&self) -> Vec<KeyRef> {
match self.axes {
Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(),
None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }],
}
}
/// [`Self::key_refs`] as `keys.select` entries.
pub(crate) fn select_entries(&self) -> Vec<serde_json::Value> {
self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect()
}
}
/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a
@@ -320,6 +337,21 @@ pub(crate) struct PathFrame {
/// Layer time of the frame.
pub time: Tick,
pub pos: Pos2,
/// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`.
pub axes: Option<[u64; 2]>,
}
/// Layer times of the keys of separated X and Y Position together, in order.
fn separated_key_times(x: &Property, y: &Property) -> Vec<Tick> {
let mut times: Vec<Tick> = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect();
times.sort();
times.dedup();
times
}
/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers).
fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] {
[x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0]
}
/// The keyframe dot on a motion path under `pos`.
@@ -361,44 +393,61 @@ pub(crate) fn motion_path(
hits: &mut Vec<KeyHit>,
frames: &mut Vec<PathFrame>,
) {
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
if pos.keys.len() < 2 || l.is_3d() {
let Some(tr) = l.transform() else { return };
let Some(pos) = tr.get("position") else { return };
// Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys.
let sep = tr.get("positionX").zip(tr.get("positionY"));
let key_times: Vec<Tick> = match sep {
Some((x, y)) => separated_key_times(x, y),
None => pos.keys.iter().map(|k| k.time).collect(),
};
if key_times.len() < 2 || l.is_3d() {
return;
}
let prop = sep.map_or(pos.uid, |(x, _)| x.uid);
let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]);
let value_at = |t: Tick| match sep {
Some((x, y)) => separated_value(x, y, t),
None => pos.value_at(t).as_vec3(),
};
let p2c = parent_to_comp(ectx, l);
let to_scr = |v: [f64; 3]| {
let c = p2c.apply(gv2(v[0], v[1]));
map.to_screen([c.x, c.y])
};
// Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI.
let times: Vec<Tick> = pos.keys.iter().map(|k| k.time).collect();
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&times, l.layer_time(ectx.time)) else { return };
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return };
let fd = ectx.comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
while lt <= lt1 && pts.len() < 20_000 {
let q = to_scr(pos.value_at(lt).as_vec3());
frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q });
let q = to_scr(value_at(lt));
frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes });
pts.push(q);
lt += fd;
}
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
pts.push(to_scr(value_at(lt1)));
let faint = col.gamma_multiply(0.8);
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
for q in &pts {
painter.circle_filled(*q, 1.2, faint);
}
let sel = &app.session.state.selected_keys;
for (i, k) in pos.keys.iter().enumerate() {
if k.time < lt0 || k.time > lt1 {
for (i, kt) in key_times.iter().enumerate() {
if *kt < lt0 || *kt > lt1 {
continue;
}
let v = k.value.as_vec3();
// The combined Position key at this time; none with separated dimensions.
let key = pos.keys.get(i).filter(|_| sep.is_none());
let v = key.map_or_else(|| value_at(*kt), |k| k.value.as_vec3());
let s = to_scr(v);
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt))));
let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes };
let selected = hit.key_refs().iter().any(|k| sel.contains(k));
// A muted position key is off the motion path: no tangents, and a dimmed point.
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
if !k.muted {
let kc = if key.is_some_and(|k| k.muted) { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
// Separated X / Y have no spatial tangents.
if let Some(k) = key.filter(|k| !k.muted) {
// 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 {
@@ -416,7 +465,7 @@ pub(crate) fn motion_path(
painter.circle_filled(h, 3.0, kc);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None });
}
}
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
@@ -427,7 +476,7 @@ pub(crate) fn motion_path(
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
hits.push(hit);
}
}
@@ -544,7 +593,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
#[derive(Clone, Debug)]
pub(crate) enum Drag {
/// A motion-path keyframe: its value moves with the pointer (parent space).
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> },
/// A spatial tangent handle of a motion-path key.
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
@@ -569,11 +618,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P
match h.tangent {
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
None => {
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
if !app.session.state.selected_keys.contains(&k) || shift {
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
// Separated dimensions: both X and Y need a key here before the drag moves them.
let mut h = *h;
for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) {
let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()}));
*has = true;
}
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift {
let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift}));
}
let axes = h.axes.map(|a| a.map(|(uid, _)| uid));
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes })
}
}
}
@@ -639,17 +694,22 @@ pub(crate) fn update(
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
) -> Option<Drag> {
match d.clone() {
Drag::Key { layer, prop, time, start, press, inv } => {
Drag::Key { layer, prop, time, start, press, inv, axes } => {
// Snap the key's on-screen position like a layer drag.
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
let s = snap(app, target, layer);
target = [target[0] + s[0], target[1] + s[1]];
let p = inv.apply(gv2(target[0], target[1]));
let v = json!([p.x, p.y, start[2]]);
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
let edits = match axes {
Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))],
None => vec![(prop, json!([p.x, p.y, start[2]]))],
};
for (uid, v) in edits {
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
None
}
@@ -714,6 +774,42 @@ pub(crate) fn update(
mod tests {
use super::*;
fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property {
use effectcraft_engine::keyframe::{Keyframe, Value};
let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0));
p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect();
p
}
/// #2242: with separated dimensions the motion path follows the keys of X and Y Position.
#[test]
fn separated_motion_path_uses_x_and_y_keys() {
let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]);
let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]);
assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]);
let mid = separated_value(&x, &y, Tick(50));
assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}");
assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]);
}
/// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key.
#[test]
fn separated_key_dot_refers_to_its_axis_keys() {
let mut h = KeyHit {
layer: LayerId(7),
prop: 1,
time: Tick(50),
pos: Pos2::ZERO,
tangent: None,
value: [0.0; 3],
p2c: Mat3::IDENTITY,
axes: Some([(1, false), (2, true)]),
};
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]);
h.axes = None;
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]);
}
fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo {
let n = vertices.len();
let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() };