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 <noreply@anthropic.com>
This commit is contained in:
parent
4373c24962
commit
782a67f228
|
|
@ -68,10 +68,16 @@ enum Gesture {
|
||||||
Camera {
|
Camera {
|
||||||
tool: Tool,
|
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 {
|
Scale {
|
||||||
layer: LayerId,
|
layer: LayerId,
|
||||||
start_scale: [f64; 3],
|
start_scale: [f64; 3],
|
||||||
|
/// The grabbed point relative to the anchor, in layer pixels.
|
||||||
start_local: [f64; 2],
|
start_local: [f64; 2],
|
||||||
|
anchor: [f64; 2],
|
||||||
|
axes: [bool; 2],
|
||||||
|
/// Comp → layer pixels as the drag began.
|
||||||
inv: Mat3,
|
inv: Mat3,
|
||||||
},
|
},
|
||||||
Rotate {
|
Rotate {
|
||||||
|
|
@ -242,6 +248,32 @@ fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64
|
||||||
Some((m, pts, b))
|
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 {
|
fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool {
|
||||||
let mut sign = 0.0;
|
let mut sign = 0.0;
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
|
|
@ -1043,8 +1075,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||||
layer: h.layer,
|
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 => {
|
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();
|
let layer = comp.layer(lid).cloned();
|
||||||
layer.and_then(|layer| {
|
layer.and_then(|layer| {
|
||||||
let tr = layer.transform()?;
|
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 inv = l2c.inverse()?;
|
||||||
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
|
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
|
||||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||||
let _ = hi;
|
Some(Gesture::Scale {
|
||||||
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 })
|
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) {
|
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
|
||||||
let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
|
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()) {
|
if !vertex_hits.iter().any(|h| h.tangent.is_none()) {
|
||||||
let _ = app.session.execute("edit.deselectAll", json!({}));
|
let _ = app.session.execute("edit.deselectAll", json!({}));
|
||||||
}
|
}
|
||||||
Some(Gesture::Marquee { start: pos })
|
Some(Gesture::Marquee { start: press })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => 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}));
|
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]));
|
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||||
// Local point relative to the anchor in *unscaled* layer space.
|
let v = drag_scale(start_scale, start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, mods.shift);
|
||||||
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 _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||||
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::<Option<Gesture>>(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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Gesture::Rotate { layer, center, start_angle, start_rot } => {
|
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();
|
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
||||||
|
|
|
||||||
|
|
@ -743,3 +743,79 @@ fn double_press_shortcuts_reveal_their_second_set() {
|
||||||
h.run_steps(40);
|
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));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue