From 80325c005dd97b33dfee2f94b582a30e6e1e471e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 20:58:10 +0100 Subject: [PATCH 1/4] Free Transform: a proportional corner drag follows the pointer along the diagonal The corner scaled by the larger of the two axis factors, so a drag along one axis did nothing until the pointer also crossed the other axis. The factor is now the projection of the pointer offset onto the diagonal from the anchor (the opposite corner, or the reference point with Alt) to the dragged corner, measured in the box's own lengths. Shift (free scaling), the legacy mapping, edges and the flip past the anchor are unchanged. Fixes #11096 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 3 + .../src/tools/transform_tool/drag_modes.rs | 13 +- .../ui-egui/src/tools/transform_tool/tests.rs | 7 +- .../src/transform_corner_projection_tests.rs | 118 ++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/transform_corner_projection_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b9a1380e9a2..02f40a2330b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -800,6 +800,9 @@ mod transform_type_tests; #[cfg(test)] mod transform_several_tests; +#[cfg(test)] +mod transform_corner_projection_tests; + #[cfg(test)] mod psd_open_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs index 5b416845dea..975acf826aa 100644 --- a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs +++ b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs @@ -342,8 +342,17 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods } } if corner && !mods.shift { - let k = ku.abs().max(kv.abs()); - (ku, kv) = (k * ku.signum(), k * kv.signum()); + // The corner follows the pointer's projection onto the diagonal from the anchor + // to the dragged corner (#11096), measured in the box's physical lengths so the + // aspect ratio is kept. Past the anchor `k` is negative and the box flips. + let (hu, hv) = (if mu.0 { 0.0 } else { 1.0 }, if mv.0 { 0.0 } else { 1.0 }); + let w = (g.quad0[1][0] - g.quad0[0][0]).hypot(g.quad0[1][1] - g.quad0[0][1]); + let hgt = (g.quad0[3][0] - g.quad0[0][0]).hypot(g.quad0[3][1] - g.quad0[0][1]); + let (dw, dh) = ((hu - au) * w, (hv - av) * hgt); + let len2 = dw * dw + dh * dh; + let k = if len2 > 1e-12 { ((u - au) * w * dw + (v - av) * hgt * dh) / len2 } else { 1.0 }; + let k = if k.is_finite() { k } else { 1.0 }; + (ku, kv) = (k, k); } let r = [au - au * ku, av - av * kv, au + (1.0 - au) * ku, av + (1.0 - av) * kv]; let map = |a: f64, b: f64| { diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index cdc178e6977..2f6551dabc2 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -888,7 +888,8 @@ fn split_of_a_grid_cuts_one_section_and_keeps_the_rest() { fn corner_drag_scales_proportionally_about_opposite_corner() { let mut s = session(); drag(&mut s, [100.0, 50.0], [200.0, 60.0], egui::Modifiers::NONE); - assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad); + // The pointer's projection onto the diagonal: (100·100 + 60·50) / (100² + 50²) = 1.84 (#11096). + assert!(close(s.quad, corners([0.0, 0.0, 184.0, 92.0])), "{:?}", s.quad); let mut s = session(); drag(&mut s, [100.0, 50.0], [200.0, 60.0], egui::Modifiers::SHIFT); assert!(close(s.quad, corners([0.0, 0.0, 200.0, 60.0])), "shift = free: {:?}", s.quad); @@ -1896,7 +1897,9 @@ fn the_aspect_link_decides_whether_handle_drags_keep_proportions() { assert!((w - 32.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6, "{case}"); } else { assert_eq!((w - h).abs() < 1e-6, proportional, "{case}"); - assert!((w - 32.0).abs() < 1e-6, "{case}"); + // A proportional corner follows the projection onto its diagonal: (32·16 + 16·16) / (16² + 16²) = 1.5. + let want = if proportional && !at_edge { 24.0 } else { 32.0 }; + assert!((w - want).abs() < 1e-6, "{case}"); } } } diff --git a/crates/ui-egui/src/transform_corner_projection_tests.rs b/crates/ui-egui/src/transform_corner_projection_tests.rs new file mode 100644 index 00000000000..e58ed904b83 --- /dev/null +++ b/crates/ui-egui/src/transform_corner_projection_tests.rs @@ -0,0 +1,118 @@ +//! Free Transform: a proportional corner drag follows the pointer's projection onto the box +//! diagonal (#11096), so a drag along one axis no longer leaves the box stuck. + +use crate::state::TransformSession; +use crate::transform_tool::{Gesture, Hit, apply_drag, hit}; + +/// A 700 × 400 box with its top-left at (100, 100), turned by `deg` degrees about its centre. +fn session(deg: f64) -> TransformSession { + let (x0, y0, x1, y1) = (100.0, 100.0, 800.0, 500.0); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let (sn, cs) = deg.to_radians().sin_cos(); + let turn = |[x, y]: [f64; 2]| [cx + (x - cx) * cs - (y - cy) * sn, cy + (x - cx) * sn + (y - cy) * cs]; + TransformSession { + session: 1, + layer: 1, + rect: [x0, y0, x1, y1], + quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(turn), + pivot: [cx, cy], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +/// Drag corner `i` of `s` from where it is by `(dx, dy)` rotated into the box's frame by `deg`. +fn drag(s: &mut TransformSession, i: usize, deg: f64, d: [f64; 2], mods: egui::Modifiers) { + let (sn, cs) = deg.to_radians().sin_cos(); + let from = s.quad[i]; + let g = Gesture { hit: hit(s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot }; + assert_eq!(g.hit, Hit::Corner(i)); + apply_drag(s, g, [from[0] + d[0] * cs - d[1] * sn, from[1] + d[0] * sn + d[1] * cs], mods); +} + +fn side(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} + +fn size(s: &TransformSession) -> (f64, f64) { + (side(s.quad[0], s.quad[1]), side(s.quad[0], s.quad[3])) +} + +fn near(got: (f64, f64), want: (f64, f64)) { + assert!((got.0 - want.0).abs() <= 1.0 && (got.1 - want.1).abs() <= 1.0, "got {got:?}, want {want:?}"); +} + +/// The reference's factor: the pointer offset projected onto the anchor-to-corner diagonal. +fn factor(anchor: [f64; 2], corner: [f64; 2], p: [f64; 2]) -> f64 { + let (d, o) = ([corner[0] - anchor[0], corner[1] - anchor[1]], [p[0] - anchor[0], p[1] - anchor[1]]); + (o[0] * d[0] + o[1] * d[1]) / (d[0] * d[0] + d[1] * d[1]) +} + +#[test] +fn the_four_measured_drags_give_the_reference_sizes() { + for (d, want) in [([-200.0, 0.0], (549.0, 314.0)), ([0.0, -100.0], (657.0, 375.0)), ([-100.0, -60.0], (599.0, 342.0)), ([100.0, 0.0], (775.0, 443.0))] { + let mut s = session(0.0); + drag(&mut s, 2, 0.0, d, egui::Modifiers::NONE); + near(size(&s), want); + // The opposite corner (the anchor) stays put. + assert_eq!(s.quad[0], [100.0, 100.0]); + } +} + +#[test] +fn the_same_holds_for_a_box_turned_30_degrees() { + for (d, k) in [([-200.0, 0.0], 0.7846), ([0.0, -100.0], 0.9385), ([-100.0, -60.0], 599.0 / 700.0), ([100.0, 0.0], 1.1077)] { + let mut s = session(30.0); + let (anchor, corner) = (s.quad[0], s.quad[2]); + let from = corner; + drag(&mut s, 2, 30.0, d, egui::Modifiers::NONE); + let (sn, cs) = 30f64.to_radians().sin_cos(); + let p = [from[0] + d[0] * cs - d[1] * sn, from[1] + d[0] * sn + d[1] * cs]; + let want = factor(anchor, corner, p); + assert!((want - k).abs() < 0.005, "formula {want} vs measured {k}"); + near(size(&s), (700.0 * want, 400.0 * want)); + assert!(side(s.quad[0], anchor) < 1e-6, "the anchor stays put"); + // The dragged corner lies on the diagonal, at the projected distance. + assert!(side(s.quad[2], [anchor[0] + (corner[0] - anchor[0]) * want, anchor[1] + (corner[1] - anchor[1]) * want]) < 1e-6); + } +} + +#[test] +fn alt_projects_from_the_reference_point() { + let mut s = session(0.0); + s.pivot = [300.0, 200.0]; + let (anchor, corner) = (s.pivot, s.quad[2]); + drag(&mut s, 2, 0.0, [-200.0, 0.0], egui::Modifiers::ALT); + let want = factor(anchor, corner, [corner[0] - 200.0, corner[1]]); + // The box scales about the reference point, which stays where it is. + assert!((s.quad[2][0] - (anchor[0] + (corner[0] - anchor[0]) * want)).abs() < 1e-6); + assert!((s.quad[2][1] - (anchor[1] + (corner[1] - anchor[1]) * want)).abs() < 1e-6); + assert_eq!(s.pivot, [300.0, 200.0]); +} + +#[test] +fn shift_still_scales_each_axis_freely() { + let mut s = session(0.0); + drag(&mut s, 2, 0.0, [-200.0, 0.0], egui::Modifiers::SHIFT); + near(size(&s), (500.0, 400.0)); +} + +#[test] +fn a_pointer_past_the_anchor_flips_the_box() { + let mut s = session(0.0); + // Corner at (800, 500), anchor (100, 100): the pointer goes to (0, 0), past the anchor. + drag(&mut s, 2, 0.0, [-800.0, -500.0], egui::Modifiers::NONE); + let k = factor([100.0, 100.0], [800.0, 500.0], [0.0, 0.0]); + assert!(k < 0.0); + near(size(&s), (700.0 * -k, 400.0 * -k)); + assert!(s.quad[2][0] < s.quad[0][0] || s.quad[2][1] < s.quad[0][1] || s.quad[1][0] < s.quad[0][0], "{:?}", s.quad); + assert_eq!(s.quad[0], [100.0, 100.0]); +} From a10ca990cc5e9d4a964264ad5eb87b87fe69de05 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 21:15:49 +0100 Subject: [PATCH 2/4] Free Transform corner tests: compare the anchor with a tolerance Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/transform_corner_projection_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/transform_corner_projection_tests.rs b/crates/ui-egui/src/transform_corner_projection_tests.rs index e58ed904b83..5daddd00515 100644 --- a/crates/ui-egui/src/transform_corner_projection_tests.rs +++ b/crates/ui-egui/src/transform_corner_projection_tests.rs @@ -63,7 +63,7 @@ fn the_four_measured_drags_give_the_reference_sizes() { drag(&mut s, 2, 0.0, d, egui::Modifiers::NONE); near(size(&s), want); // The opposite corner (the anchor) stays put. - assert_eq!(s.quad[0], [100.0, 100.0]); + assert!(side(s.quad[0], [100.0, 100.0]) < 1e-6); } } @@ -114,5 +114,5 @@ fn a_pointer_past_the_anchor_flips_the_box() { assert!(k < 0.0); near(size(&s), (700.0 * -k, 400.0 * -k)); assert!(s.quad[2][0] < s.quad[0][0] || s.quad[2][1] < s.quad[0][1] || s.quad[1][0] < s.quad[0][0], "{:?}", s.quad); - assert_eq!(s.quad[0], [100.0, 100.0]); + assert!(side(s.quad[0], [100.0, 100.0]) < 1e-6); } From 87daffae7909ab9194fa21a1c3b2c03e2e1533a2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 01:43:21 +0100 Subject: [PATCH 3/4] Retrigger CI Co-Authored-By: Claude Sonnet 5.5 From ae481e7a1a02f8a7b1f7607db84ce789b1d59650 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 23:50:17 +0100 Subject: [PATCH 4/4] Project the corner drag onto the on-screen diagonal for distorted boxes (#11096) Co-Authored-By: Claude Sonnet 5.5 --- .../src/tools/transform_tool/drag_modes.rs | 27 ++++++++++++------ .../src/transform_corner_projection_tests.rs | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs index 5ca31852d87..6fe9872c8c8 100644 --- a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs +++ b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs @@ -366,15 +366,26 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods } } if corner && !mods.shift { - // The corner follows the pointer's projection onto the diagonal from the anchor - // to the dragged corner (#11096), measured in the box's physical lengths so the - // aspect ratio is kept. Past the anchor `k` is negative and the box flips. + // The corner follows the pointer's projection onto the on-screen diagonal, the + // straight line from the anchor to the dragged corner in canvas pixels (#11096). + // The projected point is mapped back into the box's unit square, where it lies on + // that square's diagonal, and the whole box scales by that factor about the + // anchor; so it also holds for a distorted quad. Past the anchor `k` is negative + // and the box flips. let (hu, hv) = (if mu.0 { 0.0 } else { 1.0 }, if mv.0 { 0.0 } else { 1.0 }); - let w = (g.quad0[1][0] - g.quad0[0][0]).hypot(g.quad0[1][1] - g.quad0[0][1]); - let hgt = (g.quad0[3][0] - g.quad0[0][0]).hypot(g.quad0[3][1] - g.quad0[0][1]); - let (dw, dh) = ((hu - au) * w, (hv - av) * hgt); - let len2 = dw * dw + dh * dh; - let k = if len2 > 1e-12 { ((u - au) * w * dw + (v - av) * hgt * dh) / len2 } else { 1.0 }; + let (ax, ay) = h.apply(au, av); + let (cx, cy) = h.apply(hu, hv); + let (dx, dy) = (cx - ax, cy - ay); + let len2 = dx * dx + dy * dy; + let k = if len2 > 1e-12 { + let t = ((p[0] - ax) * dx + (p[1] - ay) * dy) / len2; + match inv.apply(ax + t * dx, ay + t * dy) { + (pu, _) if (hu - au).abs() >= (hv - av).abs() => (pu - au) / (hu - au), + (_, pv) => (pv - av) / (hv - av), + } + } else { + 1.0 + }; let k = if k.is_finite() { k } else { 1.0 }; (ku, kv) = (k, k); } diff --git a/crates/ui-egui/src/transform_corner_projection_tests.rs b/crates/ui-egui/src/transform_corner_projection_tests.rs index 5daddd00515..e7280e6ac7d 100644 --- a/crates/ui-egui/src/transform_corner_projection_tests.rs +++ b/crates/ui-egui/src/transform_corner_projection_tests.rs @@ -116,3 +116,31 @@ fn a_pointer_past_the_anchor_flips_the_box() { assert!(s.quad[2][0] < s.quad[0][0] || s.quad[2][1] < s.quad[0][1] || s.quad[1][0] < s.quad[0][0], "{:?}", s.quad); assert!(side(s.quad[0], [100.0, 100.0]) < 1e-6); } + +/// The box after a Distort step: not a rectangle, so the physical diagonal and the unit-square +/// diagonal differ (#11096). +fn distorted() -> TransformSession { + let mut s = session(0.0); + s.quad = [[250.0, 250.0], [950.0, 250.0], [872.0, 710.0], [250.0, 650.0]]; + s.pivot = [580.0, 465.0]; + s +} + +#[test] +fn a_distorted_box_follows_the_on_screen_diagonal() { + // Reference rows: pointer offset from the dragged corner, then the new TR, BR and BL. + let rows: [([f64; 2], [[f64; 2]; 3]); 4] = [ + ([-100.0, 0.0], [[869.18, 250.0], [807.36, 662.19], [250.0, 613.35]]), + ([0.0, -60.0], [[913.88, 250.0], [843.32, 688.79], [250.0, 633.86]]), + ([-100.0, -60.0], [[833.98, 250.0], [778.67, 640.98], [250.0, 596.77]]), + ([100.0, 0.0], [[1032.95, 250.0], [936.64, 757.81], [250.0, 685.67]]), + ]; + for (d, want) in rows { + let mut s = distorted(); + drag(&mut s, 2, 0.0, d, egui::Modifiers::NONE); + assert!(side(s.quad[0], [250.0, 250.0]) < 1e-6, "the anchor stays put"); + for (i, w) in [(1, want[0]), (2, want[1]), (3, want[2])] { + assert!(side(s.quad[i], w) <= 0.5, "drag {d:?}: corner {i} is {:?}, want {w:?}", s.quad[i]); + } + } +}