Twirl, Pinch and Spherize fill the inscribed ellipse (#5643) (#5871)

They acted inside a circle fitted to the bounds' shorter side, so on a wide
or tall image only a central disc changed. Like Photoshop's, they now act
inside the ellipse inscribed in the bounds (selection or layer), reaching all
four edges; square bounds are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Higgins
2026-10-18 03:33:04 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c408c83a9f
commit 16774eea6f
2 changed files with 54 additions and 19 deletions
+36 -19
View File
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let t = a * (1.0 - r / rr);
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let (s, c) = t.sin_cos();
(cx + dx * c - dy * s, cy + dx * s + dy * c)
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
})
}
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+18
View File
@@ -414,6 +414,24 @@ fn twirl_keeps_centre_and_outside() {
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
}
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
#[test]
fn round_distortions_fill_the_inscribed_ellipse() {
let b = Rect::new(0, 0, 80, 40);
let s = pattern(SampleType::F32, b);
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
let out = apply(&s, &p, b, b, None);
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
}
}
}
#[test]
fn motion_blur_spreads_along_angle_only() {
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);