mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
2 files changed
+124
-49
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user