mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Rotation tool: keep counting past a full turn, as in After Effects
The viewer's Rotation tool wrote start rotation + (atan2 angle now - atan2 angle at press), so the value jumped a full revolution back whenever the pointer crossed atan2's ±180° seam (left of the anchor) and could never pass 360°. Sum the per-frame angle change, wrapped to (-180°, 180°], instead: the value keeps growing across revolutions in both directions (1x +0.0°, -1x -90.0°…), Shift snaps the accumulated value to 45°, other selected layers follow, and one drag is still one undo step. Non-finite angles turn by nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9efe75330c
commit
e9c8d703a7
@@ -91,7 +91,11 @@ enum Gesture {
|
||||
Rotate {
|
||||
layer: LayerId,
|
||||
center: Pos2,
|
||||
start_angle: f64,
|
||||
/// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it).
|
||||
last_angle: f64,
|
||||
/// The turn so far, summed frame by frame so it keeps counting past a full revolution in
|
||||
/// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°.
|
||||
turned: f64,
|
||||
start_rot: f64,
|
||||
/// The other selected layers and their rotations: they turn by the same angle.
|
||||
others: Vec<(LayerId, f64)>,
|
||||
@@ -447,6 +451,26 @@ fn snap_handle(f: [f64; 2], h: [f64; 2], a: [f64; 2], l2c: &Mat3, axes: [bool; 2
|
||||
[0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] })
|
||||
}
|
||||
|
||||
/// The turn from angle `from` to angle `to` (degrees), wrapped to (−180°, 180°]: the shortest way
|
||||
/// round, so summing it frame by frame follows the pointer across atan2's ±180° seam and past full
|
||||
/// revolutions. Non-finite input turns by nothing.
|
||||
fn turn_delta(from: f64, to: f64) -> f64 {
|
||||
let d = to - from;
|
||||
if !d.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let w = (d + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if w <= -180.0 { w + 360.0 } else { w }
|
||||
}
|
||||
|
||||
/// The Rotation tool's value: the rotation as the drag began plus the turn so far (which may span
|
||||
/// revolutions), snapped to 45° steps with Shift.
|
||||
fn rotate_value(start_rot: f64, turned: f64, shift: bool) -> f64 {
|
||||
let r = start_rot + turned;
|
||||
let r = if r.is_finite() { r } else { start_rot };
|
||||
if shift { (r / 45.0).round() * 45.0 } else { r }
|
||||
}
|
||||
|
||||
/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held.
|
||||
fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] {
|
||||
if !shift {
|
||||
@@ -1368,7 +1392,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Gesture::Rotate {
|
||||
layer: l,
|
||||
center,
|
||||
start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
|
||||
last_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
|
||||
turned: 0.0,
|
||||
start_rot: rot,
|
||||
others,
|
||||
}
|
||||
@@ -1570,12 +1595,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Rotate { layer, center, start_angle, start_rot, others } => {
|
||||
Gesture::Rotate { layer, center, last_angle, turned, start_rot, others } => {
|
||||
// Count the turn incrementally: the absolute angle wraps at ±180°, so writing it
|
||||
// straight would jump a revolution back each time the pointer went round.
|
||||
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
||||
let mut r = start_rot + (ang - start_angle);
|
||||
if mods.shift {
|
||||
r = (r / 45.0).round() * 45.0;
|
||||
}
|
||||
let turned = turned + turn_delta(last_angle, ang);
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, center, last_angle: ang, turned, start_rot, others: others.clone() }));
|
||||
// Shift snaps the accumulated value (so 405° snaps to 1x +45°, not to 45°).
|
||||
let r = rotate_value(start_rot, turned, mods.shift);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
||||
for (lid, r0) in others {
|
||||
let _ =
|
||||
@@ -2611,6 +2638,48 @@ mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::geom::{Mat4, vec3};
|
||||
|
||||
/// The Rotation tool counts past a full turn in both directions, as in After Effects: the
|
||||
/// pointer going round the anchor 1.5 turns clockwise gives 540°, then back 2 turns gives
|
||||
/// −180°, with no jump where atan2 wraps from 180° to −180°.
|
||||
#[test]
|
||||
fn rotate_tool_accumulates_revolutions() {
|
||||
// Screen y points down, so a growing atan2 angle is clockwise on screen.
|
||||
let drag = |start: f64, steps: &[f64]| {
|
||||
let (mut last, mut turned, mut out) = (start, 0.0, vec![]);
|
||||
for &a in steps {
|
||||
// atan2's range: (−180°, 180°].
|
||||
let a = (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
turned += turn_delta(last, a);
|
||||
last = a;
|
||||
out.push(turned);
|
||||
}
|
||||
(last, turned, out)
|
||||
};
|
||||
let cw: Vec<f64> = (1..=54).map(|i| i as f64 * 10.0).collect();
|
||||
let (last, turned, seq) = drag(0.0, &cw);
|
||||
assert!((turned - 540.0).abs() < 1e-9, "{turned}");
|
||||
assert!(seq.windows(2).all(|w| (w[1] - w[0] - 10.0).abs() < 1e-9), "no jump: {seq:?}");
|
||||
assert!((rotate_value(0.0, turned, false) - 540.0).abs() < 1e-9);
|
||||
let ccw: Vec<f64> = (1..=72).map(|i| 540.0 - i as f64 * 10.0).collect();
|
||||
let (_, back, seq) = drag(last, &ccw);
|
||||
assert!(seq.windows(2).all(|w| (w[1] - w[0] + 10.0).abs() < 1e-9), "no jump: {seq:?}");
|
||||
let total = turned + back;
|
||||
assert!((total + 180.0).abs() < 1e-9, "{total}");
|
||||
assert!((rotate_value(30.0, total, false) + 150.0).abs() < 1e-9);
|
||||
// Shift snaps the accumulated value, not a wrapped one.
|
||||
assert_eq!(rotate_value(0.0, 400.0, true), 405.0);
|
||||
assert_eq!(rotate_value(0.0, -400.0, true), -405.0);
|
||||
// Across the seam: 170° → −170° is +20°, and back is −20°; exactly half a turn is +180°.
|
||||
assert!((turn_delta(170.0, -170.0) - 20.0).abs() < 1e-9);
|
||||
assert!((turn_delta(-170.0, 170.0) + 20.0).abs() < 1e-9);
|
||||
assert_eq!(turn_delta(0.0, 180.0), 180.0);
|
||||
assert_eq!(turn_delta(0.0, -180.0), 180.0);
|
||||
// Never crash or poison the value on non-finite input.
|
||||
assert_eq!(turn_delta(f64::NAN, 10.0), 0.0);
|
||||
assert_eq!(turn_delta(0.0, f64::INFINITY), 0.0);
|
||||
assert_eq!(rotate_value(15.0, f64::NAN, false), 15.0);
|
||||
}
|
||||
|
||||
/// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps
|
||||
/// comp pixels 1:1 onto screen pixels.
|
||||
#[test]
|
||||
|
||||
@@ -2088,3 +2088,49 @@ fn fit_up_to_100_follows_the_maximized_panel() {
|
||||
h.run_steps(3);
|
||||
assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The Rotation tool keeps counting past a full turn, as in After Effects: going round the anchor
|
||||
/// 1.5 turns clockwise gives 540° (1x +180.0°), going back 2 turns gives −180°, with no jump a
|
||||
/// revolution back where the pointer crosses the left of the anchor. One drag is one undo step.
|
||||
#[test]
|
||||
fn rotate_tool_counts_revolutions_both_ways() {
|
||||
let mut h = harness();
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::Rotate;
|
||||
h.run_steps(2);
|
||||
let rotation = |h: &Harness<'_, EffectcraftApp>| {
|
||||
h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/rotation").unwrap().value.as_f64()
|
||||
};
|
||||
let center = screen(&h, [320.0, 180.0]);
|
||||
// Inside the 80 px box, so the drag starts on it (not on the Plate behind).
|
||||
let r = (screen(&h, [350.0, 180.0]) - center).x;
|
||||
let at = |deg: f64| center + vec2((deg.to_radians().cos() as f32) * r, (deg.to_radians().sin() as f32) * r);
|
||||
let undo_before = h.state().session.history.undo.len();
|
||||
// Press on the box, right of its anchor; then go round in 10° steps.
|
||||
let press = center + vec2(r * 0.3, 0.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(press));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
let mut seen = vec![];
|
||||
let mut walk = |h: &mut Harness<'_, EffectcraftApp>, degs: Vec<f64>| {
|
||||
for d in degs {
|
||||
h.input_mut().events.push(Event::PointerMoved(at(d)));
|
||||
h.step();
|
||||
seen.push(rotation(h));
|
||||
}
|
||||
};
|
||||
walk(&mut h, (0..=54).map(|i| i as f64 * 10.0).collect());
|
||||
// The gesture applies the previous frame's pointer: hold a frame.
|
||||
h.step();
|
||||
let cw = rotation(&h);
|
||||
assert!((cw - 540.0).abs() < 1.0, "1.5 turns clockwise: {cw}");
|
||||
walk(&mut h, (1..=72).map(|i| 540.0 - i as f64 * 10.0).collect());
|
||||
let end = at(-180.0);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
let ccw = rotation(&h);
|
||||
assert!((ccw + 180.0).abs() < 1.0, "back two turns: {ccw}");
|
||||
assert!(seen.windows(2).all(|w| (w[1] - w[0]).abs() <= 20.0), "no jump: {seen:?}");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo_before + 1, "one drag, one undo step");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user