From 2720ae5fe29d721622502e34eb0504b05a46d726 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 23:40:32 +1100 Subject: [PATCH 1/3] Wave Warp: cover the Top/Bottom edge pinning paths end to end (#1181) The pinning regression only exercised All Edges (and Left Edge in the CPU unit test), so the reported 'does not work with Top/Bottom Edge' had no coverage to catch or clear it. This holds the pinned first/last row in a CPU test at Direction 90, and adds CPU/GPU parity cases for Top (4), Bottom (6), diagonal directions, and single-edge pinning on shape layers. CPU pinning verified correct on main: the guards in dst_wave_pin/wave_pin agree; the gap was that neither edge path was tested. --- crates/effects/src/distort.rs | 14 +++++++++++++- crates/gpu/src/tests_fx_distort.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index a68de109..195ac898 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -556,8 +556,20 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + } } #[test] diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index e0e89fb6..19cdcd60 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() { effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); + // #1181: the single-edge pinning paths, with the default vertical displacement direction. + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]); + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]); } #[test] @@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) { fn edge_pinning_on_a_shape_layer() { shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]); + // #1181: single-edge pinning over the layer rect, not just All Edges. + shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); + shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); shape_case( "ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))], From 604f2d0ea70ead34a3363a637b7ba340971e795a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:53:12 +0700 Subject: [PATCH 2/3] Wave Warp: the pinning test compares the middle row at matching coordinates (#1181) The old check zipped the padded output with the unpadded source, so pixels never lined up and it always passed. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 195ac898..7617fab2 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -568,7 +568,8 @@ mod tests { let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); } - assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + let mid = 15; + assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort"); } } From 31d74e268d9cdcbaed8cc1cbdb3836589b2da3af Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 03:12:36 +1100 Subject: [PATCH 3/3] Normalize cardinal Wave Warp directions to preserve CPU and GPU parity. --- crates/effects/src/distort.rs | 5 ++++- crates/gpu/src/fx_distort.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 7617fab2..534da806 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("waveType"); let pin = ctx.params.e("pinning"); let seed = ctx.seed ^ 0x3a7e; - let (dx, dy) = (dir.sin(), -dir.cos()); + // Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave + // boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU. + let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v }; + let (dx, dy) = (axis(dir.sin()), -axis(dir.cos())); if !ctx.adjustment { b.pad(h.abs().ceil() as u32 + 1); } diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index a62ad683..ce713382 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -111,7 +111,10 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let dir = ctx.params.f("direction").to_radians(); let speed = ctx.params.f("speed"); let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU; - let (dx, dy) = (dir.sin(), -dir.cos()); + // Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave + // boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU. + let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v }; + let (dx, dy) = (axis(dir.sin()), -axis(dir.cos())); if !ctx.adjustment { b.pad(e, h.abs().ceil() as u32 + 1)?; }