From e9c8d703a7f1e9ecb3bf63e9c36fa13d283c17a8 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:12:12 +0200 Subject: [PATCH] Rotation tool: keep counting past a full turn, as in After Effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ui-egui/src/panels/viewer.rs | 83 ++++++++++++++++++++++++++--- crates/ui-egui/tests/ui_viewer.rs | 46 ++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d9..fe583c8c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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 = (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 = (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] diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 07036ca8..3022cb8a 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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| { + 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"); +}