diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d084dd006..5c4272066 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs index 8a3fbf0f3..da4245e85 100644 --- a/crates/ui-egui/src/panels/viewer/tools.rs +++ b/crates/ui-egui/src/panels/viewer/tools.rs @@ -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 diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ab6d3d561..00c07425d 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -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 { + 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 { + 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 { + let mut times: Vec = 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, frames: &mut Vec, ) { - 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 = 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 = pos.keys.iter().map(|k| k.time).collect(); - let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, 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 { 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() };