Merge pull request #74 from storytold/fix/63-viewer-scale

Fix #63: scaling a layer by its handles follows the pointer
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 23:44:30 +07:00
commit 0dcbe325ab
2 files changed
+124 -49

No files matched your search

+48 -49
View File
@@ -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::<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));
}
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();
+76
View File
@@ -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));
}