diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 4dca9381..e7ca4910 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1,7 +1,1056 @@ -//! Distort effects, batch 3. +//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, +//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the +//! fifteen warp styles) and Detail-preserving Upscale. +//! +//! All warps are inverse mapped: every output pixel finds the source point that lands on it. +//! Pixel-sized parameters are multiplied by the buffer scale (downsampled previews). -use crate::EffectSpec; +use std::f64::consts::{PI, TAU}; + +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, pick, point_in_poly, poly_length, poly_point_at, premul, remap, src_at, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +/// Mask choices for mask popups ("None", "Mask 1" … "Mask 8"). +const MASK_CHOICES: [&str; 9] = ["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8"]; + +/// The chosen mask as a polyline in buffer pixels (`None` for "None" or a missing mask). +fn mask_px(ctx: &EffectCtx, b: &Buf, id: &str) -> Option<(Vec<[f64; 2]>, bool)> { + let i = ctx.params.e(id) as usize; + if i == 0 { + return None; + } + let m = ctx.env.masks.get(i - 1)?; + if m.points.len() < 2 { + return None; + } + Some((m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed)) +} + +/// Resample a polyline to `n` points evenly spaced by arc length. +fn resample_poly(pts: &[[f64; 2]], closed: bool, n: usize) -> Vec<[f64; 2]> { + let len = poly_length(pts, closed); + let div = if closed { n as f64 } else { (n - 1).max(1) as f64 }; + (0..n).map(|i| poly_point_at(pts, closed, len * i as f64 / div).0).collect() +} + +fn centroid(pts: &[[f64; 2]]) -> [f64; 2] { + let n = pts.len().max(1) as f64; + let s = pts.iter().fold([0.0, 0.0], |a, q| [a[0] + q[0], a[1] + q[1]]); + [s[0] / n, s[1] / n] +} + +/// Elasticity popup (Stiff … Super Fluid) → inverse-distance weighting exponent. +const ELASTICITY: [&str; 8] = ["Stiff", "Less Stiff", "Below Normal", "Normal", "Above Normal", "Loose", "Liquid", "Super Fluid"]; +fn elastic_exp(i: u32) -> f64 { + [1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)] +} + +// ---------------------------------------------------------------- CC Bender + +fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount") / 100.0; + if amount == 0.0 { + return b; + } + let style = ctx.params.e("style"); + let top = b.to_px(ctx.params.v2("top")); + let base = b.to_px(ctx.params.v2("base")); + let (ax, ay) = (top.0 - base.0, top.1 - base.1); + let len = (ax * ax + ay * ay).sqrt().max(1e-6); + let (ux, uy) = (ax / len, ay / len); + // Perpendicular (to the right of the axis when it points up). + let (nx, ny) = (-uy, ux); + let reference = if ctx.params.b("adjustToDistance") { len } else { ctx.layer_size[1] * b.scale }; + let f = move |t: f64| -> f64 { + let t = t.clamp(0.0, 1.0); + match style { + 1 => 0.5 * (PI * t).sin() * t, // Marilyn: billowing middle + 2 => 0.5 * t, // Sharp: straight lean from the base + 3 => 0.25 * (1.0 - (PI * t).cos()) - 0.1 * (TAU * t).sin(), // Boxer: S-shaped + _ => 0.5 * t * t, // Bend: smooth curve + } + }; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - base.0, y - base.1); + let t = (rx * ux + ry * uy) / len; + let d = amount * reference * f(t); + Some((x - nx * d, y - ny * d)) + }); + b +} + +// ---------------------------------------------------------------- shading helpers + +/// Unit light vector from AE-style direction (0° = up, clockwise) and height (0–100 %). +fn distant_light(dir_deg: f64, height_pct: f64) -> [f64; 3] { + let el = (height_pct / 100.0).clamp(0.0, 1.0) * PI * 0.5; + let d = dir_deg.to_radians(); + [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()] +} + +fn norm3(v: [f64; 3]) -> [f64; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +// ---------------------------------------------------------------- CC Blobbylize + +fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let soft = ctx.params.f("softness").max(0.0) * b.scale; + let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32; + let blob = layer_or_self(ctx, &b, "blobLayer", true, false); + let prop = src_at(ctx.params.e("property")); + let raw = Plane::from_image(&blob, |px| { + let (c, a) = unpremul(px); + pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a } + }); + let h = gauss_plane(&raw, soft, soft); + let intensity = ctx.params.f("lightIntensity") / 100.0; + let lc = ctx.params.color("lightColor"); + let point = ctx.params.e("lightType") == 1; + let lpos = b.to_px(ctx.params.v2("lightPosition")); + let lheight = ctx.params.f("lightHeight"); + let ldir = distant_light(ctx.params.f("lightDirection"), lheight); + let ambient = ctx.params.f("ambient") / 100.0; + let diffuse = ctx.params.f("diffuse") / 100.0; + let specular = ctx.params.f("specular") / 100.0; + let rough = ctx.params.f("roughness").clamp(0.001, 1.0); + let metal = (ctx.params.f("metal") / 100.0) as f32; + let shininess = (1.0 / rough).min(500.0); + let depth = (soft * 0.5).max(1.0); + let src = b.img.clone(); + let (w, hh) = (b.img.width as usize, b.img.height as usize); + let span = (w.max(hh) as f64).max(1.0); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (xi, yi) = (x as i64, y as i64); + let hv = h.get(x, y); + let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; + let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; + // Refraction-like displacement down the slope, then the blobby matte. + let sx = x as f64 + 0.5 - gx * depth * 2.0; + let sy = y as f64 + 0.5 - gy * depth * 2.0; + let s = src.sample_bilinear(sx, sy); + let (c, a) = unpremul(s); + let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0); + let alpha = a.max(s[3]) * m; + if alpha <= 0.0 { + *px = [0.0; 4]; + continue; + } + let n = norm3([-gx * depth, -gy * depth, 1.0]); + let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir }; + let ndl = dot3(n, l).max(0.0); + let hv3 = norm3([l[0], l[1], l[2] + 1.0]); + let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular; + let mut out = [0.0f32; 3]; + for i in 0..3 { + let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i]; + let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i]; + out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col; + } + *px = premul(out, alpha); + } + }); + b +} + +// ---------------------------------------------------------------- CC Power Pin + +fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + // Source rectangle (layer space), grown by the Expand percentages. + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + // Grow the buffer to hold the pinned quad. + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners_layer { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(need.ceil().min(4096.0) as u32 + 1); + } + } + let q = corners_layer.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let src = b.img.clone(); + let (sc, off) = (b.scale, b.offset); + let bil = move |u: f64, v: f64| -> (f64, f64) { + let top = (q[0].x + (q[1].x - q[0].x) * u, q[0].y + (q[1].y - q[0].y) * u); + let bot = (q[3].x + (q[2].x - q[3].x) * u, q[3].y + (q[2].y - q[3].y) * u); + (top.0 + (bot.0 - top.0) * v, top.1 + (bot.1 - top.1) * v) + }; + b.img = crate::util::gen_image(src.width, src.height, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let m = inv.apply(vec2(px, py)); + let (mut u, mut v) = (m.x, m.y); + if persp < 1.0 { + // Bilinear (non-perspective) inverse by Newton iterations from the projective guess. + let (mut bu, mut bv) = (u, v); + for _ in 0..8 { + let (fx, fy) = bil(bu, bv); + let (rx, ry) = (fx - px, fy - py); + if rx.abs() + ry.abs() < 1e-6 { + break; + } + let e = 1e-4; + let (ax, ay) = bil(bu + e, bv); + let (cx, cy) = bil(bu, bv + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (cx - fx) / e, (cy - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-12 { + break; + } + bu -= (j11 * rx - j01 * ry) / det; + bv -= (-j10 * rx + j00 * ry) / det; + } + u = bu + (u - bu) * persp; + v = bv + (v - bv) * persp; + } + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let lx = x0 + u * (x1 - x0); + let ly = y0 + v * (y1 - y0); + src.sample_bilinear(lx * sc + off[0], ly * sc + off[1]) + }); + b +} + +// ---------------------------------------------------------------- CC Ripple Pulse + +fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; // the ring crosses half the layer in one time span + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + let wave = move |r: f64| -> f64 { + // A pulse train trailing the front, fading with age. + let age = (front - r) / speed.max(1e-9); + if age < 0.0 || age > span { + return 0.0; + } + let fade = 1.0 - age / span; + (TAU * (r - front) / lambda).sin() * fade + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt(); + let d = wave(r) * height; + let (ux, uy) = if r > 1e-9 { (dx / r, dy / r) } else { (0.0, 0.0) }; + let mut s = src.sample_bilinear(x as f64 + 0.5 - ux * d, y as f64 + 0.5 - uy * d); + if bump && d != 0.0 { + let slope = (wave(r + 0.5) - wave(r - 0.5)) * height; + let k = (1.0 - slope * 0.5).clamp(0.0, 2.0) as f32; + for v in s.iter_mut().take(3) { + *v *= k; + } + } + *px = s; + } + }); + b +} + +// ---------------------------------------------------------------- CC Slant + +fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("slant") / 100.0; + let height = ctx.params.f("height") / 100.0; + if k == 0.0 && (height - 1.0).abs() < 1e-12 { + return b; + } + let stretch = ctx.params.b("stretching"); + let xs = if stretch { 1.0 / (1.0 + k * k).sqrt() } else { 1.0 }; + let hinv = if height.abs() < 1e-6 { 1e6 } else { 1.0 / height }; + if !ctx.adjustment { + let lean = (k * ctx.layer_size[1] * b.scale * height.max(1.0)).abs(); + let grow = ((height - 1.0).max(0.0) * ctx.layer_size[1] * b.scale).max(lean); + b.pad(grow.ceil().min(4096.0) as u32); + } + let floor = b.to_px(ctx.params.v2("floor")); + b.img = remap(&b.img, false, |x, y| { + let sy = floor.1 + (y - floor.1) * hinv; + let sx = floor.0 + (x - floor.0 - (floor.1 - y) * k) / xs; + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- CC Smear + +fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let reach = ctx.params.f("reach") / 100.0; + let radius = (ctx.params.f("radius") * b.scale).max(0.5); + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let l2 = dx * dx + dy * dy; + if l2 < 1e-12 || reach == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + let t = (((x - from.0) * dx + (y - from.1) * dy) / l2).clamp(0.0, 1.0); + let (px, py) = (from.0 + dx * t, from.1 + dy * t); + let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt(); + let w = (1.0 - d / radius).max(0.0); + let w = w * w * (3.0 - 2.0 * w); + let k = reach * w * t; + Some((x - dx * k, y - dy * k)) + }); + b +} + +// ---------------------------------------------------------------- CC Split / CC Split 2 + +fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { + if s1 == 0.0 && s2 == 0.0 { + return b; + } + let a = b.to_px(ctx.params.v2("pointA")); + let c = b.to_px(ctx.params.v2("pointB")); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return b; + } + let (ux, uy) = (dx / len, dy / len); + let (nx, ny) = (-uy, ux); + let reach = len * 0.5; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - a.0, y - a.1); + let t = (rx * ux + ry * uy) / len; + let s = rx * nx + ry * ny; + if !(0.0..=1.0).contains(&t) { + return Some((x, y)); + } + let amt = if s >= 0.0 { s1 } else { s2 }; + let g = amt * reach * (PI * t).sin(); + let ad = s.abs(); + if ad < g { + return None; + } + // Push the sides apart, fading out over `reach` beyond the gap. + let push = g * (1.0 - (ad - g) / reach).max(0.0); + let ns = s.signum() * (ad - push); + Some((x + nx * (ns - s), y + ny * (ns - s))) + }); + b +} + +fn cc_split(ctx: &EffectCtx, b: Buf) -> Buf { + let s = ctx.params.f("split") / 100.0; + split_impl(ctx, b, s, s) +} + +fn cc_split2(ctx: &EffectCtx, b: Buf) -> Buf { + split_impl(ctx, b, ctx.params.f("split1") / 100.0, ctx.params.f("split2") / 100.0) +} + +// ---------------------------------------------------------------- Smear (mask based) + +fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((src_m, _)), Some((bound, _))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "boundaryMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + let off = ctx.params.v2("maskOffset"); + let (ox, oy) = (off[0] * b.scale, off[1] * b.scale); + let rot = ctx.params.f("maskRotation").to_radians(); + let scl = (ctx.params.f("maskScale") / 100.0).max(1e-3); + let kexp = elastic_exp(ctx.params.e("elasticity")); + let c = centroid(&src_m); + let (sn, cs) = rot.sin_cos(); + let fwd = |p: [f64; 2]| -> [f64; 2] { + let (dx, dy) = ((p[0] - c[0]) * scl, (p[1] - c[1]) * scl); + [c[0] + dx * cs - dy * sn + ox, c[1] + dx * sn + dy * cs + oy] + }; + let inv = |x: f64, y: f64| -> (f64, f64) { + let (dx, dy) = (x - ox - c[0], y - oy - c[1]); + (c[0] + (dx * cs + dy * sn) / scl, c[1] + (-dx * sn + dy * cs) / scl) + }; + let moved: Vec<[f64; 2]> = src_m.iter().map(|&q| fwd(q)).collect(); + if pct == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + if !point_in_poly(&bound, x, y) { + return Some((x, y)); + } + let w = if point_in_poly(&moved, x, y) { + 1.0 + } else { + let dm = dist_to_poly(&moved, true, x, y); + let db = dist_to_poly(&bound, true, x, y); + (db / (db + dm).max(1e-9)).powf(1.0 / kexp) + }; + let (ix, iy) = inv(x, y); + let k = (w * pct).clamp(0.0, 1.0); + Some((x + (ix - x) * k, y + (iy - y) * k)) + }); + b +} + +// ---------------------------------------------------------------- Reshape + +fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + if pct == 0.0 { + return b; + } + let bound = mask_px(ctx, &b, "boundaryMask").map(|m| m.0); + let kexp = elastic_exp(ctx.params.e("elasticity")); + const N: usize = 48; + let s = resample_poly(&sm, sc, N); + let d = resample_poly(&dm, dc, N); + // Align the starting correspondence point (closed shapes): best cyclic rotation. + let rot = if sc && dc { + (0..N) + .min_by(|&r1, &r2| { + let cost = |r: usize| (0..N).map(|i| (s[(i + r) % N][0] - d[i][0]).powi(2) + (s[(i + r) % N][1] - d[i][1]).powi(2)).sum::(); + cost(r1).total_cmp(&cost(r2)) + }) + .unwrap_or(0) + } else { + 0 + }; + let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect(); + let smooth = ctx.params.e("interpolation") == 2; + b.img = remap(&b.img, false, |x, y| { + if let Some(bd) = &bound + && !point_in_poly(bd, x, y) + { + return Some((x, y)); + } + let (mut wx, mut wy, mut ws) = (0.0, 0.0, 0.0); + let mut dmin = f64::INFINITY; + for i in 0..N { + let d2 = (x - d[i][0]).powi(2) + (y - d[i][1]).powi(2); + dmin = dmin.min(d2); + let w = 1.0 / (d2 + 1e-6).powf(kexp * 0.5); + wx += w * disp[i][0]; + wy += w * disp[i][1]; + ws += w; + } + let mut fade = 1.0; + if let Some(bd) = &bound { + let db = dist_to_poly(bd, true, x, y); + let dq = dmin.sqrt(); + fade = db / (db + dq).max(1e-9); + if smooth { + fade = fade * fade * (3.0 - 2.0 * fade); + } + } + let k = fade * pct / ws; + Some((x + wx * k, y + wy * k)) + }); + b +} + +// ---------------------------------------------------------------- Twirl (Legacy) + +fn twirl_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return b; + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + b.img = remap(&b.img, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let d = (dx * dx + dy * dy).sqrt(); + if d >= r { + return Some((x, y)); + } + // The legacy twirl falls off linearly with distance from the centre. + let a = -ang * (1.0 - d / r); + let (s, co) = a.sin_cos(); + Some((c.0 + dx * co - dy * s, c.1 + dx * s + dy * co)) + }); + b +} + +// ---------------------------------------------------------------- Warp + +const WARP_STYLES: [&str; 15] = + ["Arc", "Arc Lower", "Arc Upper", "Arch", "Bulge", "Shell Lower", "Shell Upper", "Flag", "Wave", "Fish", "Rise", "Fisheye", "Inflate", "Squeeze", "Twist"]; + +/// Forward warp in normalised coordinates (u across the warp axis' length, v across it, both +/// −1…1); `k` is Bend (−1…1). Returns the warped point. +fn warp_style(style: u32, k: f64, u: f64, v: f64) -> (f64, f64) { + let bell = 1.0 - u * u; + match style { + 0 => (u * (1.0 - 0.3 * k * v), v - k * bell), // Arc + 1 => (u, v - k * bell * (v + 1.0) * 0.5), // Arc Lower + 2 => (u, v - k * bell * (1.0 - v) * 0.5), // Arc Upper + 3 => (u, v - k * bell), // Arch + 4 => (u, v * (1.0 + k * bell)), // Bulge + 5 => (u, v + k * bell * bell * (v + 1.0) * 0.5), // Shell Lower + 6 => (u, v - k * bell * bell * (1.0 - v) * 0.5), // Shell Upper + 7 => (u, v - 0.5 * k * (PI * u).sin()), // Flag + 8 => (u, v - 0.5 * k * (PI * u + 0.5 * PI * v).sin()), // Wave + 9 => (u, v * (1.0 + 0.5 * k * (PI * u).sin())), // Fish + 10 => (u, v - 0.5 * k * (0.5 * PI * u).sin()), // Rise + 11 => { + // Fisheye: radial bulge inside the unit circle. + let r2 = u * u + v * v; + if r2 >= 1.0 { + (u, v) + } else { + let f = 1.0 + k * (1.0 - r2); + (u * f, v * f) + } + } + 12 => (u * (1.0 + 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Inflate + 13 => (u * (1.0 - 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Squeeze + _ => { + // Twist: rotation decreasing towards the edge of the unit circle. + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + (u, v) + } else { + let a = k * PI * (1.0 - r); + let (s, c) = a.sin_cos(); + (u * c - v * s, u * s + v * c) + } + } + } +} + +fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("bend") / 100.0; + let hd = ctx.params.f("horizontalDistortion") / 100.0; + let vd = ctx.params.f("verticalDistortion") / 100.0; + if k == 0.0 && hd == 0.0 && vd == 0.0 { + return b; + } + let style = ctx.params.e("warpStyle"); + let vertical = ctx.params.e("warpAxis") == 1; + let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5); + if !ctx.adjustment { + let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0); + b.pad(pad as u32 + 1); + } + let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]); + let (hx, hy) = (hx.max(1e-6), hy.max(1e-6)); + let fwd = move |x: f64, y: f64| -> (f64, f64) { + let (u0, v0) = ((x - c.0) / hx, (y - c.1) / hy); + let (u, v) = if vertical { (v0, u0) } else { (u0, v0) }; + let (wu, wv) = warp_style(style, k, u, v); + let (mut x1, mut y1) = if vertical { (wv, wu) } else { (wu, wv) }; + // Horizontal / vertical distortion: perspective-like size change across the layer. + y1 *= 1.0 + 0.5 * hd * x1; + x1 *= 1.0 + 0.5 * vd * y1; + (c.0 + x1 * hx, c.1 + y1 * hy) + }; + b.img = remap(&b.img, false, |x, y| { + let (mut sx, mut sy) = (x, y); + for _ in 0..12 { + let (fx, fy) = fwd(sx, sy); + let (rx, ry) = (fx - x, fy - y); + if rx.abs() + ry.abs() < 1e-7 { + break; + } + let e = 0.01; + let (ax, ay) = fwd(sx + e, sy); + let (bx, by) = fwd(sx, sy + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (bx - fx) / e, (by - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-9 { + return None; + } + sx -= (j11 * rx - j01 * ry) / det; + sy -= (-j10 * rx + j00 * ry) / det; + } + let (fx, fy) = fwd(sx, sy); + if (fx - x).abs() + (fy - y).abs() > 0.5 { + return None; + } + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- Detail-preserving Upscale + +/// Catmull-Rom weight. +fn cubic_w(t: f64) -> f64 { + let t = t.abs(); + if t < 1.0 { + 1.5 * t * t * t - 2.5 * t * t + 1.0 + } else if t < 2.0 { + -0.5 * t * t * t + 2.5 * t * t - 4.0 * t + 2.0 + } else { + 0.0 + } +} + +fn bicubic(img: &Image, x: f64, y: f64) -> Px { + let fx = x - 0.5; + let fy = y - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + let (tx, ty) = (fx - x0, fy - y0); + let wx = [cubic_w(1.0 + tx), cubic_w(tx), cubic_w(1.0 - tx), cubic_w(2.0 - tx)]; + let wy = [cubic_w(1.0 + ty), cubic_w(ty), cubic_w(1.0 - ty), cubic_w(2.0 - ty)]; + let mut o = [0.0f64; 4]; + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let p = img.get_clamped(x0 as i64 - 1 + i as i64, y0 as i64 - 1 + j as i64); + let w = wxi * wyj; + for c in 0..4 { + o[c] += p[c] as f64 * w; + } + } + } + let a = o[3].clamp(0.0, 1.0) as f32; + [(o[0] as f32).clamp(0.0, a.max(o[0] as f32)), (o[1] as f32).clamp(0.0, a.max(o[1] as f32)), (o[2] as f32).clamp(0.0, a.max(o[2] as f32)), a] +} + +fn upscale(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = (ctx.params.f("scale") / 100.0).clamp(1.0, 10.0); + if (k - 1.0).abs() < 1e-9 { + return b; + } + let detail = (ctx.params.f("detail") / 100.0) as f32; + let noise = ctx.params.f("reduceNoise") / 100.0; + let src = if noise > 0.0 { effectcraft_raster::gaussian_blur(&b.img, noise * 1.5, noise * 1.5, true) } else { b.img.clone() }; + let lc = [ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]; + let mut out = if ctx.adjustment { + // Scale in place about the layer centre. + let c = b.to_px(lc); + let mut o = Image::new(src.width, src.height); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let sx = c.0 + (x as f64 + 0.5 - c.0) / k; + let sy = c.1 + (y as f64 + 0.5 - c.1) / k; + *px = if sx < 0.0 || sy < 0.0 || sx > src.width as f64 || sy > src.height as f64 { [0.0; 4] } else { bicubic(&src, sx, sy) }; + } + }); + o + } else { + let w = ((src.width as f64 * k).round() as u32).clamp(1, 16384); + let h = ((src.height as f64 * k).round() as u32).clamp(1, 16384); + let (kx, ky) = (w as f64 / src.width as f64, h as f64 / src.height as f64); + let mut o = Image::new(w, h); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = bicubic(&src, (x as f64 + 0.5) / kx, (y as f64 + 0.5) / ky); + } + }); + let c_sc = [lc[0] * b.scale, lc[1] * b.scale]; + b.offset = [b.offset[0] * kx + c_sc[0] * (kx - 1.0), b.offset[1] * ky + c_sc[1] * (ky - 1.0)]; + o + }; + if detail > 0.0 { + let sigma = 0.6 * k; + let blur = effectcraft_raster::gaussian_blur(&out, sigma, sigma, true); + out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| { + let a = p[3]; + for c in 0..3 { + p[c] = (p[c] + (p[c] - q[c]) * detail * 1.5).clamp(0.0, a.max(p[c])); + } + }); + } + b.img = out; + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let pct = || slider(0.0, 100.0, 0.0, 100.0, 1); + vec![ + spec( + "ec.distort.ccbender", + "CC Bender", + vec![ + p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])), + p("adjustToDistance", "Adjust To Distance", Value::Bool(false), ParamUi::Checkbox), + p("top", "Top", pt(0.5, 0.0), ParamUi::Point), + p("base", "Base", pt(0.5, 1.0), ParamUi::Point), + ], + cc_bender, + ), + spec( + "ec.distort.ccblobbylize", + "CC Blobbylize", + vec![ + p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])), + p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)), + p("cutAway", "Cut Away", num(0.0), pct()), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), + p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("metal", "Metal", num(100.0), pct()), + ], + cc_blobbylize, + ), + spec( + "ec.distort.ccpowerpin", + "CC Power Pin", + vec![ + p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point), + p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point), + p("bottomLeft", "Bottom Left", pt(0.0, 1.0), ParamUi::Point), + p("bottomRight", "Bottom Right", pt(1.0, 1.0), ParamUi::Point), + p("perspective", "Perspective", num(100.0), pct()), + p("unstretch", "Unstretch", Value::Bool(false), ParamUi::Checkbox), + p("expandTop", "Top", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandLeft", "Left", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandRight", "Right", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandBottom", "Bottom", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + ], + cc_power_pin, + ), + spec( + "ec.distort.ccripplepulse", + "CC Ripple Pulse", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("amplitude", "Amplitude", num(100.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("renderBump", "Render Bump As Well", Value::Bool(false), ParamUi::Checkbox), + ], + cc_ripple_pulse, + ), + spec( + "ec.distort.ccslant", + "CC Slant", + vec![ + p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox), + p("height", "Height", num(100.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)), + p("floor", "Floor", pt(0.5, 1.0), ParamUi::Point), + ], + cc_slant, + ), + spec( + "ec.distort.ccsmear", + "CC Smear", + vec![ + p("from", "From", pt(0.35, 0.5), ParamUi::Point), + p("to", "To", pt(0.65, 0.5), ParamUi::Point), + p("reach", "Reach", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("radius", "Radius", num(50.0), slider(0.0, 4000.0, 0.0, 400.0, 1)), + ], + cc_smear, + ), + spec( + "ec.distort.ccsplit", + "CC Split", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split", "Split", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split, + ), + spec( + "ec.distort.ccsplit2", + "CC Split 2", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split1", "Split 1", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("split2", "Split 2", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split2, + ), + spec( + "ec.distort.smear", + "Smear", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("maskOffset", "Mask Offset", pt(0.0, 0.0), ParamUi::Point), + p("maskRotation", "Mask Rotation", num(0.0), ParamUi::Angle), + p("maskScale", "Mask Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(3), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + smear, + ), + spec( + "ec.distort.reshape", + "Reshape", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("destinationMask", "Destination Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + reshape, + ), + spec( + "ec.distort.twirllegacy", + "Twirl (Legacy)", + vec![ + p("angle", "Angle", num(0.0), ParamUi::Angle), + p("radius", "Twirl Radius", num(30.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("center", "Twirl Center", pt(0.5, 0.5), ParamUi::Point), + ], + twirl_legacy, + ), + spec( + "ec.distort.warp", + "Warp", + vec![ + p("warpStyle", "Warp Style", Value::Enum(0), popup(&WARP_STYLES)), + p("warpAxis", "Warp Axis", Value::Enum(0), popup(&["Horizontal", "Vertical"])), + p("bend", "Bend", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("horizontalDistortion", "Horizontal Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("verticalDistortion", "Vertical Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + ], + warp, + ), + spec( + "ec.distort.upscale", + "Detail-preserving Upscale", + vec![ + p("scale", "Scale", num(100.0), slider(100.0, 1000.0, 100.0, 400.0, 1)), + p("detail", "Detail", num(20.0), pct()), + p("reduceNoise", "Reduce Noise", num(0.0), pct()), + ], + upscale, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + fn img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) + } + + /// Run with Point defaults scaled to the layer (like `instantiate`). + fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [im.width as f64, im.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let env = EffectEnv { masks, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn maxd(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { + MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } + } + + #[test] + fn identity_at_neutral_settings() { + let im = img(40, 30); + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![]), + ("ec.distort.ccpowerpin", vec![]), + ("ec.distort.ccripplepulse", vec![]), + ("ec.distort.ccslant", vec![]), + ("ec.distort.ccsmear", vec![("reach", num(0.0))]), + ("ec.distort.ccsplit", vec![("split", num(0.0))]), + ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), + ("ec.distort.smear", vec![]), + ("ec.distort.reshape", vec![]), + ("ec.distort.twirllegacy", vec![]), + ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), + ("ec.distort.upscale", vec![]), + ]; + for (id, set) in cases { + let o = run(id, set, im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); + assert_eq!(o.offset, [0.0, 0.0], "{id}"); + } + // Power pin with perspective 0 at default corners is also identity. + let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-4); + } + + #[test] + fn all_deterministic() { + let im = img(36, 28); + let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), + ("ec.distort.ccblobbylize", vec![]), + ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), + ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), + ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), + ("ec.distort.ccsmear", vec![]), + ("ec.distort.ccsplit", vec![]), + ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), + ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), + ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), + ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), + ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), + ("ec.distort.upscale", vec![("scale", num(200.0))]), + ]; + for (id, set) in cases { + let a = run(id, set, im.clone(), &masks); + let b = run(id, set, im.clone(), &masks); + assert_eq!(a.img, b.img, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); + } + } + + #[test] + fn every_warp_style_runs_and_bends() { + let im = img(40, 30); + for s in 0..WARP_STYLES.len() as u32 { + for axis in 0..2 { + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as u32; + // The layer centre's neighbourhood is still made of source pixels. + assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); + assert!(o.img.width > im.width); + } + } + // Arc with positive bend raises the middle of the layer: the top edge in the middle + // moves up (opaque pixels above the original top). + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[1] as i64; + assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); + assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); + } + + #[test] + fn power_pin_moves_corners() { + let im = img(20, 20); + // Pin to the right half: output x in 10..20 shows the whole layer squeezed. + let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; + let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(o.img.get(3, 10)[3], 0.0); + let px = o.img.get(15, 10); + assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); + } + + #[test] + fn split_opens_a_gap() { + let im = img(40, 30); + let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); + assert_eq!(o.img.get(20, 15)[3], 0.0); + assert_eq!(o.img.get(2, 2), im.get(2, 2)); + } + + #[test] + fn slant_shears_from_floor() { + let im = img(20, 20); + let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as i64; + // Bottom row stays put; the top row shifts right by ~10 px. + assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); + assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); + } + + #[test] + fn reshape_moves_source_to_destination() { + // A bright square at the source mask; reshape to a shifted destination mask. + let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); + assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); + assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); + } + + #[test] + fn upscale_grows_about_centre() { + let im = img(20, 10); + let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); + assert_eq!((o.img.width, o.img.height), (40, 20)); + // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the + // centre (10,5) lands in the middle of the 40×20 buffer. + assert_eq!(o.offset, [10.0, 5.0]); + let c = o.img.sample_bilinear(20.0, 10.0); + assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); + } + + #[test] + fn twirl_legacy_rotates_inside_radius() { + let im = img(40, 40); + let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) > 0.1); + // Corners lie outside the radius. + assert_eq!(o.img.get(0, 0), im.get(0, 0)); + } + + #[test] + fn half_resolution_matches_scaled_geometry() { + // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. + let s = crate::find("ec.distort.ccbender").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("amount".into(), num(30.0)); + params.values.insert("top".into(), pt(20.0, 0.0)); + params.values.insert("base".into(), pt(20.0, 40.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; + let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); + let a = full.img.sample_bilinear(20.0, 4.0)[3]; + let b = half.img.sample_bilinear(10.0, 2.0)[3]; + assert!((a - b).abs() < 0.3); + } }