diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index c1d90dd2..660e9b4b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -68,10 +68,16 @@ enum Gesture { Camera { tool: Tool, }, + /// A bounding-box handle: scales about the anchor point so the grabbed point follows the + /// pointer (corners both axes, edges one), relative to the layer as the drag began. Scale { layer: LayerId, start_scale: [f64; 3], + /// The grabbed point relative to the anchor, in layer pixels. start_local: [f64; 2], + anchor: [f64; 2], + axes: [bool; 2], + /// Comp → layer pixels as the drag began. inv: Mat3, }, Rotate { @@ -242,6 +248,32 @@ fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64 Some((m, pts, b)) } +/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) +/// y; the right and left edges (5, 7) x. +fn handle_axes(handle: usize) -> [bool; 2] { + match handle { + 4 | 6 => [false, true], + 5 | 7 => [true, false], + _ => [true, true], + } +} + +/// The scale that puts the point grabbed at `start` (relative to the anchor, layer pixels) +/// under the pointer at `cur`, for a layer whose scale was `start_scale` when the drag began. +/// `proportional` (Shift on a corner) keeps the aspect ratio, following the pointer along the +/// handle's diagonal. Dragging past the anchor flips the layer, as in After Effects; an axis +/// whose grabbed point sits on the anchor keeps its scale. +fn drag_scale(start_scale: [f64; 3], start: [f64; 2], cur: [f64; 2], axes: [bool; 2], proportional: bool) -> [f64; 3] { + let ratio = |k: usize| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }; + let (mut fx, mut fy) = (ratio(0), ratio(1)); + if proportional && axes == [true, true] { + let d2 = start[0] * start[0] + start[1] * start[1]; + let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 }; + (fx, fy) = (f, f); + } + [start_scale[0] * fx, start_scale[1] * fy, start_scale[2]] +} + fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { let mut sign = 0.0; for i in 0..4 { @@ -1043,8 +1075,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer: h.layer, } }), + // Everything starts where the button went down: by the time the pointer has moved + // far enough to count as a drag it may have left a small handle. Tool::Selection => { - if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { + let cpt = map.to_comp(press); + if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { let tr = layer.transform()?; @@ -1052,8 +1087,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); let lp = inv.apply(gv2(cpt[0], cpt[1])); - let _ = hi; - Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv }) + Some(Gesture::Scale { + layer: lid, + start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), + start_local: [lp.x - a[0], lp.y - a[1]], + anchor: [a[0], a[1]], + axes: handle_axes(hi), + inv, + }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app @@ -1085,7 +1126,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !vertex_hits.iter().any(|h| h.tangent.is_none()) { let _ = app.session.execute("edit.deselectAll", json!({})); } - Some(Gesture::Marquee { start: pos }) + Some(Gesture::Marquee { start: press }) } } _ => None, @@ -1121,52 +1162,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, inv, .. } => { + Gesture::Scale { layer, start_scale, start_local, anchor, axes, inv } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); - // Local point relative to the anchor in *unscaled* layer space. - let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]); - let cur = [lp.x - a[0], lp.y - a[1]]; - let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 }; - let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 }; - let (fx, fy) = if mods.shift { - let f = (fx + fy) / 2.0; - (f, f) - } else { - (if start_local[0].abs() > 1e-3 { fx } else { 1.0 }, if start_local[1].abs() > 1e-3 { fy } else { 1.0 }) - }; - // The local point moved within the *current* scale; compose with the start scale. - let cs = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "scale", [100.0; 3]))).unwrap_or([100.0; 3]); - let nx = cs[0] * fx; - let ny = cs[1] * fy; - let _ = start_scale; - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": [nx, ny, cs[2]], "merge": merge})); - ui.data_mut(|d| { - if let Some(Gesture::Scale { start_local, .. }) = d.get_temp_mut_or_default::>(egui::Id::new("unused")).as_mut() { - let _ = start_local; - } - }); - // Re-baseline so the next frame's ratios are relative to the new scale. - let lp_unscaled = [cur[0] / fx.max(1e-9) * fx, cur[1] / fy.max(1e-9) * fy]; - let mut g2 = gesture.clone(); - if let Some(Gesture::Scale { start_local, inv: i2, .. }) = g2.as_mut() { - *start_local = lp_unscaled; - if let Some(l) = app.session.active_comp().and_then(|c| c.layer(layer)).cloned() { - let e2 = EvalCtx { - project: &app.session.project, - comp_id: cid, - comp: app.session.project.comp(cid).unwrap_or(&comp), - time, - expr: app.session.expr.as_deref(), - footage: None, - }; - if let Some(m) = l2c(&e2, &l).0.inverse() { - *i2 = m; - } - } - } - if let Some(g2) = g2 { - ui.data_mut(|d| d.insert_temp(gid, g2)); - } + let v = drag_scale(start_scale, start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, mods.shift); + let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); } Gesture::Rotate { layer, center, start_angle, start_rot } => { let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 835536bb..557b67a8 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -743,3 +743,79 @@ fn double_press_shortcuts_reveal_their_second_set() { h.run_steps(40); } } + +/// Drag through `path` with `modifiers` held (press at the first point, release at the last). +fn drag_path(h: &mut Harness<'_, EffectcraftApp>, path: &[Pos2], modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(path[0])); + h.input_mut().events.push(Event::PointerButton { pos: path[0], button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for w in path.windows(2) { + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(w[0] + (w[1] - w[0]) * (i as f32 / 6.0))); + h.step(); + } + } + let end = *path.last().unwrap(); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(2); + // The keys come up after the button, as a hand does. + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); +} + +/// Issue #63: a bounding-box handle scales about the anchor point so the grabbed corner follows +/// the pointer, relative to the scale the drag began with: dragging through the anchor and back +/// out recovers (it used to stick at 0), edges scale one axis, Shift keeps the proportions. +#[test] +fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| { + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": path, "value": v})).unwrap(); + }; + let scale = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0; + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + // The 80×80 box is centred at (320, 180) with its anchor in the middle. + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.run_steps(2); + let undo0 = h.state().session.history.undo.len(); + // Bottom-right corner (360, 220) to (400, 260): twice as far from the anchor. + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [400.0, 260.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [200.0, 200.0]), "{:?}", scale(&h)); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step per drag"); + // Through the anchor (scale ≈ 0) and back out to 1.5×: the layer follows, nothing sticks. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [320.0, 180.0]), screen(&h, [300.0, 170.0]), screen(&h, [380.0, 240.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [150.0, 150.0]), "{:?}", scale(&h)); + // Past the anchor the layer flips, as in After Effects. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [280.0, 140.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [-100.0, -100.0]), "{:?}", scale(&h)); + // The right edge (handle 5) scales x only. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let edge = rect(&h, &format!("viewer.handle.{}.5", box_id.0)).center(); + let path = [edge, screen(&h, [380.0, 200.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [150.0, 100.0]), "{:?}", scale(&h)); + // Shift on a corner keeps the proportions: the pointer's place along the diagonal. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [400.0, 230.0])]; + drag_path(&mut h, &path, egui::Modifiers::SHIFT); + assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); +}