diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index b72242dd..9e2f1250 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -20,7 +20,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.distort.reshape", "no correspondence points"), ("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"), ("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"), - ("ec.generate.radiowaves", "no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile"), ("ec.generate.scribble", "no caps, joins or Start/End Apply To"), ("ec.generate.cellpattern", "HQ variants render like the standard ones"), ("ec.key.innerouter", "one additional mask per side; no cleanup strokes"), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a3..9dc26333 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -349,60 +349,275 @@ fn outline_dist(pts: &[(f64, f64)], dx: f64, dy: f64) -> f64 { best } +/// Radio Waves' Wave Type options. +const RW_TYPES: [&str; 3] = ["Polygon", "Image Contours", "Mask"]; +/// Radio Waves' Stroke Profile options. +const RW_PROFILES: [&str; 7] = ["Square", "Taper Front", "Taper Back", "Taper Both", "Sine", "Sawtooth Out", "Sawtooth In"]; +/// Image Contour's Value Channel options. +const RW_CHANNELS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation"]; + +/// Stroke Profile weight across the stroke: `u` from −1 (inner edge) to 1 (outer edge). +fn rw_profile(kind: u32, u: f64) -> f64 { + let u = u.clamp(-1.0, 1.0); + match kind { + 1 => (1.0 - u) * 0.5, + 2 => (1.0 + u) * 0.5, + 3 => 1.0 - u.abs(), + 4 => (u * PI * 0.5).cos(), + 5 if u < 0.0 => 1.0 + u, + 6 if u > 0.0 => 1.0 - u, + _ => 1.0, + } +} + +/// Fold a coordinate into `[lo, hi]` by bouncing off the ends (Reflection). +fn reflect(v: f64, lo: f64, hi: f64) -> f64 { + let span = hi - lo; + if span <= 0.0 { + return lo; + } + let t = (v - lo).rem_euclid(2.0 * span); + lo + if t > span { 2.0 * span - t } else { t } +} + +/// The source shape of a contour wave: signed distance (buffer px, negative inside) on the +/// buffer grid, and the point of the shape that sits on the Producer Point. +struct Contour { + sdf: Plane, + anchor: (f64, f64), +} + +/// Image Contours: the region of the source layer whose Value Channel passes Value Threshold. +fn image_contour(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let lp = ctx.layer_param("imageContour/sourceLayer", true)?; + let img = crate::util::fit_layer(ctx, b, &lp, false); + let (w, h) = (img.width as usize, img.height as usize); + let ch = pr.e("imageContour/valueChannel"); + let mut v = Plane::from_image(&img, |px| { + let (c, a) = unpremul(px); + let (hh, s, l) = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]); + match ch { + 0 => c[0] * a, + 1 => c[1] * a, + 2 => c[2] * a, + 3 => a, + 5 => hh, + 6 => l * a, + 7 => s, + _ => luminance(c[0], c[1], c[2]) * a, + } + }); + let blur = pr.f("imageContour/preBlur").max(0.0) * b.scale; + if blur > 0.0 { + v = gauss_plane(&v, blur * 0.5, blur * 0.5); + } + let thr = (pr.f("imageContour/valueThreshold") / 255.0) as f32; + let invert = pr.b("imageContour/invertInput"); + let mut inside: Vec = v.data.iter().map(|&x| (x >= thr) != invert).collect(); + // Contour: 0 = every region; n = the n-th largest connected region. + let pick = pr.f("imageContour/contour").round() as usize; + if pick > 0 { + let mut label = vec![0u32; w * h]; + let mut sizes = vec![0usize]; + for start in 0..w * h { + if !inside[start] || label[start] != 0 { + continue; + } + let id = sizes.len() as u32; + let mut stack = vec![start]; + label[start] = id; + let mut n = 0; + while let Some(i) = stack.pop() { + n += 1; + let (x, y) = (i % w, i / w); + let mut nb = |j: usize| { + if inside[j] && label[j] == 0 { + label[j] = id; + stack.push(j); + } + }; + if x > 0 { + nb(i - 1); + } + if x + 1 < w { + nb(i + 1); + } + if y > 0 { + nb(i - w); + } + if y + 1 < h { + nb(i + w); + } + } + sizes.push(n); + } + let mut order: Vec = (1..sizes.len()).collect(); + order.sort_by(|a, c| sizes[*c].cmp(&sizes[*a]).then(a.cmp(c))); + let keep = order.get(pick - 1).copied().unwrap_or(usize::MAX) as u32; + inside.iter_mut().zip(&label).for_each(|(v, l)| *v = *v && *l == keep); + } + if !inside.iter().any(|v| *v) { + return None; + } + let mut sdf = crate::util::signed_distance(&inside, w, h); + // Tolerance smooths the traced outline. + let tol = pr.f("imageContour/tolerance").max(0.0) * b.scale; + if tol > 0.0 { + sdf = gauss_plane(&sdf, tol * 0.5, tol * 0.5); + } + Some(Contour { sdf, anchor: b.to_px(pr.v2("imageContour/sourceCenter")) }) +} + +/// Mask: the chosen mask of this layer. +fn mask_contour(ctx: &EffectCtx, b: &Buf) -> Option { + let idx = ctx.params.f("waveMask/mask").round() as usize; + let (pts, _, inverted) = crate::generate3::mask_px(ctx, b, idx)?; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inside: Vec = (0..w * h).map(|i| crate::util::point_in_poly(&pts, (i % w) as f64 + 0.5, (i / w) as f64 + 0.5) != inverted).collect(); + if !inside.iter().any(|v| *v) { + return None; + } + // Mask waves start where the mask is (anchored at the Producer Point: no shift). + let anchor = b.to_px(ctx.params.v2("producerPoint")); + Some(Contour { sdf: crate::util::signed_distance(&inside, w, h), anchor }) +} + +/// One live wave. +struct Wave { + centre: (f64, f64), + radius: f64, + half_width: f64, + rotation: f64, + fade: f32, + color: [f32; 4], + opacity: f32, + /// Polygon outline (unit shape rotated and scaled), or None for a circle / contour. + outline: Option>, +} + +/// Radio Waves: waves are born at Frequency per second at the Producer Point and grow at +/// Expansion px/s for Lifespan seconds, moving at Velocity towards Direction (bouncing off the +/// layer edges with Reflection) and spinning. Polygon waves are regular polygons or stars; +/// Image Contours waves ripple outward from the outline of a source layer's thresholded +/// channel, Mask waves from a mask. With Parameters Are Set At Birth each wave keeps the values +/// it was born with; Each Frame applies the current values to every wave. Stroke draws each wave +/// with its Profile across the width. fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let c = b.to_px(ctx.params.v2("producerPoint")); - let sides = ctx.params.f("polygon/sides").round().clamp(3.0, 64.0); - let star = ctx.params.b("polygon/star"); - let depth = ctx.params.f("polygon/starDepth").clamp(-1.0, 1.0); - let freq = ctx.params.f("waveMotion/frequency").max(0.01); - let expansion = ctx.params.f("waveMotion/expansion") * b.scale; - let orient = ctx.params.f("waveMotion/orientation").to_radians(); - // Direction: 0° = up, clockwise. - let dirn = ctx.params.f("waveMotion/direction").to_radians(); - let vel = ctx.params.f("waveMotion/velocity") * b.scale; - let spin = ctx.params.f("waveMotion/spin").to_radians(); - let life = ctx.params.f("waveMotion/lifespan").max(0.01); - let color = ctx.params.color("waveStroke/color"); - let op = (ctx.params.f("waveStroke/opacity") / 100.0) as f32; - let fin = ctx.params.f("waveStroke/fadeInTime").max(0.0); - let fout = ctx.params.f("waveStroke/fadeOutTime").max(0.0); - let sw = ctx.params.f("waveStroke/startWidth") * b.scale; - let ew = ctx.params.f("waveStroke/endWidth") * b.scale; + let cur = ctx.params; let t = ctx.time.max(0.0); + let freq = cur.f("waveMotion/frequency").max(0.01); + let life_now = cur.f("waveMotion/lifespan").max(0.01); + let birth = cur.e("parametersAreSetAt") == 0; + let wave_type = cur.e("waveType"); + let quality = cur.get("renderQuality").map(Value::as_f64).unwrap_or(4.0).clamp(1.0, 10.0); + let aa = 0.5 + 1.5 / quality; + let profile = cur.e("waveStroke/profile"); + let reflection = cur.b("waveMotion/reflection"); + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let contour = match wave_type { + 1 => image_contour(ctx, &b), + 2 => mask_contour(ctx, &b), + _ => None, + }; + if wave_type != 0 && contour.is_none() { + return b; + } let k_hi = (t * freq).floor() as i64; - let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256); - // (centre, radius, half width, rotation, fade) per live wave. - let waves: Vec<((f64, f64), f64, f64, f64, f32)> = (k_lo..=k_hi) + let k_lo = (((t - life_now) * freq).floor() as i64 - 1).max(0).max(k_hi - 256); + let mut cache: std::collections::HashMap = std::collections::HashMap::new(); + let waves: Vec = (k_lo..=k_hi) .filter_map(|k| { - let age = t - k as f64 / freq; + let born = k as f64 / freq; + let age = t - born; + // Birth: the values the wave was born with (when the host can tell). + let pr: &crate::Params = if birth { + if let Some(h) = ctx.env.host + && let std::collections::hash_map::Entry::Vacant(e) = cache.entry(k) + && let Some(p) = h.params_at(born) + { + e.insert(p); + } + cache.get(&k).unwrap_or(cur) + } else { + cur + }; + let life = pr.f("waveMotion/lifespan").max(0.01); if !(0.0..life).contains(&age) { return None; } + let c = b.to_px(pr.v2("producerPoint")); + let fin = pr.f("waveStroke/fadeInTime").max(0.0); + let fout = pr.f("waveStroke/fadeOutTime").max(0.0); let fi = if fin > 0.0 { (age / fin).min(1.0) } else { 1.0 }; let fo = if fout > 0.0 { ((life - age) / fout).min(1.0) } else { 1.0 }; - let centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age); - Some((centre, expansion * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), orient + spin * age, (fi * fo) as f32)) + let dirn = pr.f("waveMotion/direction").to_radians(); + let vel = pr.f("waveMotion/velocity") * b.scale; + let mut centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age); + if reflection { + centre = (reflect(centre.0, lx, lx + lw), reflect(centre.1, ly, ly + lh)); + } + let sw = pr.f("waveStroke/startWidth") * b.scale; + let ew = pr.f("waveStroke/endWidth") * b.scale; + let radius = pr.f("waveMotion/expansion") * b.scale * age; + let rotation = pr.f("waveMotion/orientation").to_radians() + pr.f("waveMotion/spin").to_radians() * age; + let outline = (wave_type == 0).then(|| { + let sides = pr.f("polygon/sides").round().clamp(3.0, 64.0); + let star = pr.b("polygon/star"); + let depth = pr.f("polygon/starDepth").clamp(-1.0, 1.0); + if sides >= 64.0 && !star { + return None; + } + let n = sides as usize; + let unit: Vec<(f64, f64)> = if star { + (0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect() + } else { + (0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect() + }; + Some(unit.iter().map(|&(a, k)| ((a + rotation).cos() * radius * k, (a + rotation).sin() * radius * k)).collect()) + }); + Some(Wave { + centre, + radius, + half_width: ((sw + (ew - sw) * age / life) * 0.5).max(0.25), + rotation, + fade: (fi * fo) as f32, + color: pr.color("waveStroke/color"), + opacity: (pr.f("waveStroke/opacity") / 100.0) as f32, + outline: outline.flatten(), + }) }) .collect(); - let circle = sides >= 64.0 && !star; - // Unit outline (radius 1) of the polygon or star, rotated per wave below. - let n = sides as usize; - let unit: Vec<(f64, f64)> = if star { - (0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect() - } else { - (0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect() - }; - let outlines: Vec> = - waves.iter().map(|&(_, rad, _, rot, _)| unit.iter().map(|&(a, k)| ((a + rot).cos() * rad * k, (a + rot).sin() * rad * k)).collect()).collect(); map_xy(&mut b.img, |x, y, px| { - let mut a = 0.0f32; - for (wi, &(cc, rad, hw, _, fade)) in waves.iter().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - cc.0, y as f64 + 0.5 - cc.1); - let d = if circle { ((dx * dx + dy * dy).sqrt() - rad).abs() } else { outline_dist(&outlines[wi], dx, dy) }; - a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade); + let mut out = px; + // Older waves first, so newer ones sit on top. + for wv in &waves { + let (dx, dy) = (x as f64 + 0.5 - wv.centre.0, y as f64 + 0.5 - wv.centre.1); + // Signed distance from the wave line (negative inside the wave). + let d = match (&contour, &wv.outline) { + (Some(ct), _) => { + // The contour placed with its anchor on the wave centre, rotated. + let (s, c) = (-(wv.rotation)).sin_cos(); + let (rx, ry) = (dx * c - dy * s, dx * s + dy * c); + ct.sdf.sample(ct.anchor.0 + rx, ct.anchor.1 + ry) as f64 - wv.radius + } + (None, Some(o)) => { + let ud = outline_dist(o, dx, dy); + let poly: Vec<[f64; 2]> = o.iter().map(|p| [p.0, p.1]).collect(); + if crate::util::point_in_poly(&poly, dx, dy) { -ud } else { ud } + } + (None, None) => (dx * dx + dy * dy).sqrt() - wv.radius, + }; + let ad = d.abs(); + let cov = ((wv.half_width - ad) / aa + 0.5).clamp(0.0, 1.0); + if cov <= 0.0 { + continue; + } + let u = (d / wv.half_width).clamp(-1.0, 1.0); + let a = (cov * rw_profile(profile, u)) as f32 * wv.fade * wv.opacity * wv.color[3]; + out = over(out, [wv.color[0] * a, wv.color[1] * a, wv.color[2] * a, a]); } - let a = a * op * color[3]; - over(px, [color[0] * a, color[1] * a, color[2] * a, a]) + out }); b } @@ -776,9 +991,21 @@ pub fn specs() -> Vec { "Radio Waves", vec![ p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("parametersAreSetAt", "Parameters Are Set At", Value::Enum(1), popup(&["Birth", "Each Frame"])), + p("renderQuality", "Render Quality", num(4.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + p("waveType", "Wave Type", Value::Enum(0), popup(&RW_TYPES)), p("polygon/sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)), p("polygon/star", "Star", Value::Bool(false), ParamUi::Checkbox), p("polygon/starDepth", "Star Depth", num(-0.3), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("imageContour/sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer), + p("imageContour/sourceCenter", "Source Center", pt(0.5, 0.5), ParamUi::Point), + p("imageContour/valueChannel", "Value Channel", Value::Enum(4), popup(&RW_CHANNELS)), + p("imageContour/invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox), + p("imageContour/valueThreshold", "Value Threshold", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("imageContour/preBlur", "Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("imageContour/tolerance", "Tolerance", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("imageContour/contour", "Contour", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 0)), + p("waveMask/mask", "Mask", num(0.0), ParamUi::Mask), p("waveMotion/frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), p("waveMotion/expansion", "Expansion", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)), p("waveMotion/orientation", "Orientation", num(0.0), ang()), @@ -786,6 +1013,8 @@ pub fn specs() -> Vec { p("waveMotion/velocity", "Velocity", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)), p("waveMotion/spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)), p("waveMotion/lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("waveMotion/reflection", "Reflection", Value::Bool(false), ParamUi::Checkbox), + p("waveStroke/profile", "Profile", Value::Enum(0), popup(&RW_PROFILES)), p("waveStroke/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("waveStroke/opacity", "Opacity", num(100.0), pct()), p("waveStroke/fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), @@ -1014,4 +1243,108 @@ mod tests { assert!(c > o.img.get(60, 4)[0]); assert!(c > 0.5); } + + fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { + let mut v: Vec<(&str, Value)> = + vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; + v.extend_from_slice(set); + crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img + } + + #[test] + fn signed_distance_of_a_square() { + let (w, h) = (20, 20); + let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); + let d = crate::util::signed_distance(&inside, w, h); + assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); + assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); + assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); + } + + #[test] + fn radio_waves_mask_contours_profile_reflection_and_birth() { + // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. + let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; + let masks = [sq]; + let env = crate::EffectEnv { masks: &masks, ..Default::default() }; + let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); + assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); + assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); + // Rounded corner: diagonal distance 10 from (37, 37). + assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); + // No mask chosen: nothing drawn. + assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); + // Image Contours: the bright square of a source layer. + struct Src; + impl crate::EffectHost for Src { + fn layer(&self, _: u64, _: bool) -> Option { + let mut img = Image::new(64, 64); + for y in 27..37 { + for x in 27..37 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + // Colour animates from red (born at 0) to green (born later). + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + p.values.insert("waveMotion/frequency".into(), num(1.0)); + p.values.insert("waveMotion/expansion".into(), num(20.0)); + p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); + Some(p) + } + } + let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; + let ic = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); + // Source Center off the square shifts the contour onto the producer point accordingly. + let sh = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); + // Profile: Taper Both thins the stroke edges; Square is solid across. + let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; + let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); + let mut tap = wide.to_vec(); + tap.push(("waveStroke/profile", Value::Enum(3))); + let tap = rw(&tap, 0.2, crate::EffectEnv::default()); + assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); + assert!(tap.get(52, 32)[3] > 0.8); + // Reflection: a fast wave bounces back off the right edge. + let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; + let gone = rw(&fast, 0.3, crate::EffectEnv::default()); + let mut rf = fast.to_vec(); + rf.push(("waveMotion/reflection", Value::Bool(true))); + let back = rw(&rf, 0.3, crate::EffectEnv::default()); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); + // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. + let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); + let (old, new) = (both.get(55, 32), both.get(35, 32)); + assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); + assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d465059d..022e61fd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -269,6 +269,11 @@ pub trait EffectHost: Sync { fn comp_scene(&self) -> Option { None } + /// The running effect's own parameters evaluated at another **layer time** (Radio Waves' + /// Parameters Are Set At: Birth). `None` when unavailable. + fn params_at(&self, _layer_time: f64) -> Option { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). @@ -615,6 +620,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("polygon", "Polygon"), ("waveMotion", "Wave Motion"), ("waveStroke", "Stroke"), + ("imageContour", "Image Contour"), + ("waveMask", "Mask"), ("coreSettings", "Core Settings"), ("glowSettings", "Glow Settings"), ("expertSettings", "Expert Settings"), diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9249ecf9..06580b71 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -563,6 +563,78 @@ impl SimCache { } } +/// 1D squared-distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012; +/// `f` is 0 at sites and +∞ elsewhere). +fn sq_dt_1d(f: &[f64], out: &mut [f64]) { + let n = f.len(); + let sites: Vec = (0..n).filter(|&i| f[i].is_finite()).collect(); + if sites.is_empty() { + out.iter_mut().for_each(|o| *o = f64::INFINITY); + return; + } + let mut v = vec![0usize; sites.len()]; + let mut z = vec![0f64; sites.len() + 1]; + let mut k = 0usize; + v[0] = sites[0]; + z[0] = f64::NEG_INFINITY; + z[1] = f64::INFINITY; + let s_of = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64); + for &q in &sites[1..] { + let mut s = s_of(q, v[k]); + while s <= z[k] { + k -= 1; + s = s_of(q, v[k]); + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = f64::INFINITY; + } + k = 0; + for (q, o) in out.iter_mut().enumerate() { + while z[k + 1] < q as f64 { + k += 1; + } + let d = q as f64 - v[k] as f64; + *o = d * d + f[v[k]]; + } +} + +/// Euclidean distance from every pixel to the nearest `true` pixel of `mask` (`w`×`h`), +/// infinite when there is none. +pub fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec { + let mut g: Vec = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect(); + // Columns, then rows. + let cols: Vec> = (0..w) + .into_par_iter() + .map(|x| { + let f: Vec = (0..h).map(|y| g[y * w + x]).collect(); + let mut o = vec![0.0; h]; + sq_dt_1d(&f, &mut o); + o + }) + .collect(); + for (x, col) in cols.iter().enumerate() { + for y in 0..h { + g[y * w + x] = col[y]; + } + } + g.par_chunks_mut(w.max(1)).for_each(|row| { + let f = row.to_vec(); + sq_dt_1d(&f, row); + }); + g.iter().map(|&d| d.sqrt() as f32).collect() +} + +/// Signed distance to the boundary of the `inside` region: negative inside, positive outside +/// (pixel centres; ±0.5 at the boundary). +pub fn signed_distance(inside: &[bool], w: usize, h: usize) -> Plane { + let outside: Vec = inside.iter().map(|v| !v).collect(); + let to_in = distance_transform(inside, w, h); + let to_out = distance_transform(&outside, w, h); + Plane { w, h, data: to_in.iter().zip(&to_out).map(|(a, b)| if *a > 0.0 { a - 0.5 } else { -(b - 0.5) }).collect() } +} + /// Point-in-polygon (even-odd) for a closed polyline. pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool { let n = pts.len(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4db96f0e..1f736668 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -151,6 +151,13 @@ impl EffectHost for FxHost<'_, '_, '_> { } } + fn params_at(&self, layer_time: f64) -> Option { + let i = self.index.load(std::sync::atomic::Ordering::Relaxed); + let g = self.layer.effects()?.groups().nth(i)?; + let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time))); + Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) + } + fn comp_scene(&self) -> Option { // The comp camera's view of the layer (as if 3D at its transform), brought back into the // layer's own pixel grid through the inverse of how the layer itself composites. diff --git a/docs/effects.md b/docs/effects.md index d615b817..68dc9e86 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2716 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 303 effects with 2730 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 24 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 273 implemented in full, 30 partial (what is missing is listed). +- **Status**: 274 implemented in full, 29 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -208,7 +208,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Grid | `ec.generate.grid` | 12 | | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | -| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | +| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented | | Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To | | Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented | | Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |