From 782a67f228d70a36d2f436a2df639158fc5253b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 23:43:50 +0700 Subject: [PATCH] Fix #63: scaling a layer by its handles follows the pointer The handle drag rescaled incrementally from the current scale every frame: errors built up, edge handles scaled both axes, and once a factor reached ~0 the drag could never bring it back. It also tested the handle at the point where egui recognised the drag (past the drag threshold), so a quick grab often missed the handle and moved the layer instead. Now, as in After Effects, the drag scales about the anchor point relative to the layer as the drag began, so the grabbed point stays under the pointer: corners scale both axes, edges one, Shift keeps the proportions, and dragging past the anchor flips. Selection-tool gestures start at the press point. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 97 ++++++++++++++--------------- crates/ui-egui/tests/ui_viewer.rs | 76 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 49 deletions(-) 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)); +}