From 17af5c30c1f1b88ec15a33b482b541f210b88f3d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:08 -0400 Subject: [PATCH] M9.3: Generate effects (Fractal, Scribble, Stroke, Vegas, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum, Audio Waveform) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate3.rs | 1887 ++++++++++++++++++++++++++++++- 1 file changed, 1884 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index e50cf9f56..87d828673 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -1,7 +1,1888 @@ -//! Generate effects, batch 3. +//! Generate effects, batch 3: Fractal (Mandelbrot/Julia), mask-driven Scribble and Stroke, +//! Vegas (animated segments along image contours or masks), Write-on, Paint Bucket (flood +//! fill), Eyedropper Fill, CC Glue Gun, CC Threads, and the audio visualisers Audio Spectrum +//! and Audio Waveform (fed by the host's audio). +//! +//! Also home of the shared anti-aliased segment rasteriser ([`raster_segs`]) used by the text +//! effects. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, 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: "Generate", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- shared rasterisation + +/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Seg { + pub a: [f64; 2], + pub b: [f64; 2], + pub r: f64, + pub v: f32, +} + +/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction +/// of the radius that is fully opaque; the rest falls off smoothly. +pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { + let mut out = Plane::new(w, h); + if w == 0 || h == 0 || segs.is_empty() { + return out; + } + let mut buckets: Vec> = vec![Vec::new(); h]; + for (i, s) in segs.iter().enumerate() { + let pad = s.r + 1.0; + let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; + let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); + if y1 <= 0.0 || y0 >= h { + continue; + } + for row in buckets.iter_mut().take(y1 as usize).skip(y0) { + row.push(i as u32); + } + } + let hard = hardness.clamp(0.0, 1.0); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &i in &buckets[y] { + let s = segs[i as usize]; + let pad = s.r + 1.0; + let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; + let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); + let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); + let l2 = dx * dx + dy * dy; + let inner = s.r * hard - 0.5; + let outer = s.r + 0.5; + for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { + let px = x as f64 + 0.5; + let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); + if d >= outer { + continue; + } + let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; + if c > *o { + *o = c; + } + } + } + }); + out +} + +/// Segments along a polyline (buffer px). +pub(crate) fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { + let n = pts.len(); + if n == 1 { + out.push(Seg { a: pts[0], b: pts[0], r, v }); + } + if n < 2 { + return; + } + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); + } +} + +/// Portion `[s0, s1]` (arc length) of a polyline. +pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { + let n = pts.len(); + let mut out = Vec::new(); + if n < 2 || s1 <= s0 { + return out; + } + let segs = if closed { n } else { n - 1 }; + let mut acc = 0.0; + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let (e0, e1) = (acc, acc + l); + acc = e1; + if e1 < s0 || e0 > s1 || l <= 0.0 { + continue; + } + let at = |s: f64| { + let t = ((s - e0) / l).clamp(0.0, 1.0); + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + }; + let p0 = at(s0.max(e0)); + if out.last() != Some(&p0) { + out.push(p0); + } + out.push(at(s1.min(e1))); + } + out +} + +/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal +/// Original Image. +pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { + img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = (c * opacity * color[3]).clamp(0.0, 1.0); + let s = [color[0] * a, color[1] * a, color[2] * a, a]; + *px = match style { + 1 => s, + 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], + _ => over(*px, s), + }; + }); +} + +#[inline] +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. +pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { + let m = ctx.env.masks.get(idx.checked_sub(1)?)?; + if m.points.is_empty() { + 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, m.inverted)) +} + +/// All masks mapped to buffer pixels. +fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { + (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() +} + +/// Chaikin corner cutting (open polyline, endpoints kept). +fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { + let mut cur = pts.to_vec(); + for _ in 0..iters { + if cur.len() < 3 { + return cur; + } + let mut nx = Vec::with_capacity(cur.len() * 2); + nx.push(cur[0]); + for w in cur.windows(2) { + let (a, b) = (w[0], w[1]); + nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + nx.push(*cur.last().unwrap_or(&[0.0; 2])); + cur = nx; + } + cur +} + +// ---------------------------------------------------------------- Fractal + +#[derive(Clone, Copy)] +struct FracSet { + julia: bool, + inverse: bool, +} + +/// Smooth escape count, or `None` inside the set. +fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { + let (mut x, mut y) = z0; + let lim2 = limit * limit; + for i in 0..max_iter { + let (nx, ny) = match power { + 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), + 4 => { + let (a, b) = (x * x - y * y, 2.0 * x * y); + (a * a - b * b, 2.0 * a * b) + } + _ => (x * x - y * y, 2.0 * x * y), + }; + x = nx + c.0; + y = ny + c.1; + let m2 = x * x + y * y; + if m2 > lim2 { + let lz = m2.ln() * 0.5; + let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); + return Some((i as f64 + 1.0 - nu).max(0.0)); + } + } + None +} + +fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let set_choice = pr.e("setChoice"); + let power = 2 + pr.e("equation").min(2); + let mc = (pr.f("mandelbrotX"), pr.f("mandelbrotY")); + let mmag = pr.f("mandelbrotMagnification"); + let mlim = pr.f("mandelbrotEscapeLimit").max(1.01); + let jc = (pr.f("juliaX"), pr.f("juliaY")); + let jmag = pr.f("juliaMagnification"); + let jlim = pr.f("juliaEscapeLimit").max(1.01); + let post = (pr.f("postInversionX"), pr.f("postInversionY")); + let overlay = pr.b("overlay"); + let transparent_inside = pr.b("transparency") || overlay; + let palette = pr.e("palette"); + let hue = (pr.f("hue") / 360.0) as f32; + let steps = pr.f("cycleSteps").max(1.0); + let cyc_off = pr.f("cycleOffset") / 360.0; + let edge = pr.b("edgeHighlight"); + let every = pr.e("samplingMethod") == 1; + let factor = pr.f("samplingFactor").round().clamp(1.0, 9.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let lw = ctx.layer_size[0].max(1.0); + let lh = ctx.layer_size[1].max(1.0); + let inv = 1.0 / b.scale.max(1e-9); + let (off_x, off_y) = (b.offset[0], b.offset[1]); + let sets: (FracSet, Option) = match set_choice { + 1 => (FracSet { julia: false, inverse: true }, None), + 2 => (FracSet { julia: false, inverse: false }, Some(FracSet { julia: false, inverse: true })), + 3 => (FracSet { julia: false, inverse: true }, Some(FracSet { julia: false, inverse: false })), + 4 => (FracSet { julia: true, inverse: false }, None), + 5 => (FracSet { julia: true, inverse: true }, None), + _ => (FracSet { julia: false, inverse: false }, None), + }; + let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; + let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { + let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; + let span = 3.5 / 2f64.powf(mag); + let mut u = (lx - lw * 0.5) / lw * span + center.0; + let mut v = (ly - lh * 0.5) / lw * span + center.1; + if s.inverse { + let d = (u * u + v * v).max(1e-12); + u = u / d + post.0; + v = -v / d + post.1; + } + if s.julia { escape((u, v), mc, power, lim, iter_for(mag)) } else { escape((0.0, 0.0), (u, v), power, lim, iter_for(mag)) } + }; + let eval = |lx: f64, ly: f64| -> Option { + let a = eval_set(sets.0, lx, ly); + match (a, sets.1) { + (None, Some(s2)) => eval_set(s2, lx, ly), + _ => a, + } + }; + let shade = |n: Option| -> Px { + let Some(n) = n else { + return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; + }; + let band = ((n / steps + cyc_off).rem_euclid(1.0)) as f32; + let (r, g, bl) = match palette { + 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), + 2 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.3 + 0.4 * (band * 4.0).fract()), + _ => hsl_to_rgb(hue, 0.85, band.clamp(0.0, 1.0)), + }; + let mut c = [r, g, bl]; + if edge && n.fract() < 0.12 { + c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; + } + [c[0], c[1], c[2], 1.0] + }; + // First pass: one sample per pixel. + let mut first: Vec> = vec![None; w * h]; + first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); + } + }); + let src = if overlay { Some(b.img.clone()) } else { None }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let here = first[y * w + x]; + let needs_ss = factor > 1 + && (every || { + let mut diff = false; + for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let o = first[ny as usize * w + nx as usize]; + diff |= match (here, o) { + (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, + (None, None) => false, + _ => true, + }; + } + diff + }); + let c = if needs_ss { + let mut acc = [0.0f32; 4]; + for sy in 0..factor { + for sx in 0..factor { + let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; + let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; + let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); + let a = s[3]; + for k in 0..3 { + acc[k] += s[k] * a; + } + acc[3] += a; + } + } + let n = (factor * factor) as f32; + [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] + } else { + let s = shade(here); + [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] + }; + *px = match &src { + Some(o) => over(o.data[y * w + x], c), + None => c, + }; + } + }); + b +} + +// ---------------------------------------------------------------- Scribble + +/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. +#[allow(clippy::too_many_arguments)] +fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { + out.clear(); + for (pts, _closed, inverted) in polys { + let m = pts.len(); + if m < 2 { + continue; + } + // Crossings: (t along d, |sin| of crossing angle). + let mut xs: Vec<(f64, f64)> = Vec::new(); + for i in 0..m { + let (a, b) = (pts[i], pts[(i + 1) % m]); + let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); + if (na > 0.0) == (nb > 0.0) { + continue; + } + let t = na / (na - nb); + let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let el = (ex * ex + ey * ey).sqrt().max(1e-9); + let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); + xs.push((q[0] * d[0] + q[1] * d[1], sin)); + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Signed area decides which side is "left". + let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); + let left_is_inside = area < 0.0; + let mode = match fill { + 4 => { + if left_is_inside { + 2 + } else { + 3 + } + } + 5 => { + if left_is_inside { + 3 + } else { + 2 + } + } + f => f, + }; + let mode = if *inverted && mode == 0 { 6 } else { mode }; + for (k, &(t, sin)) in xs.iter().enumerate() { + let entering = k % 2 == 0; + let l = ew / sin; + match mode { + 0 => { + if entering && k + 1 < xs.len() { + out.push((t, xs[k + 1].0)); + } + } + 1 => out.push((t - l * 0.5, t + l * 0.5)), + 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), + 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), + _ => {} + } + } + if mode == 6 { + // Inverted Inside: outside of the polygon within the bounds. + let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; + let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; + let mut cur = lo; + for pair in xs.chunks(2) { + out.push((cur, pair[0].0)); + cur = pair.get(1).map(|v| v.0).unwrap_or(hi); + } + out.push((cur, hi)); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Merge overlaps. + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); + for &(a, b) in out.iter() { + if let Some(last) = merged.last_mut() + && a <= last.1 + { + last.1 = last.1.max(b); + continue; + } + merged.push((a, b)); + } + *out = merged; +} + +fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let which = pr.e("scribble"); + let polys: Vec<_> = if which == 0 { mask_px(ctx, &b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, &b) }; + let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); + let style = match pr.e("composite") { + 0 => 0, + 2 => 2, + _ => 1, + }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if polys.is_empty() { + if style == 1 { + b.img = Image::new(b.img.width, b.img.height); + } + return b; + } + let sc = b.scale; + let fill = pr.e("fillType"); + let ew = pr.f("edgeWidth").max(0.1) * sc; + let ang = pr.f("angle").to_radians(); + let d = [ang.cos(), ang.sin()]; + let n = [-d[1], d[0]]; + let width = (pr.f("strokeWidth") * sc).max(0.1); + let curv = pr.f("curviness") / 100.0; + let curv_var = pr.f("curvinessVariation") / 100.0; + let spacing = (pr.f("spacing") * sc).max(0.75).max(width * 0.25); + let spacing_var = pr.f("spacingVariation") / 100.0; + let overlap = pr.f("pathOverlap") / 100.0; + let overlap_var = pr.f("pathOverlapVariation") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let sequential = pr.b("fillPathsSequentially"); + let wiggle = pr.e("wiggleType"); + let wps = pr.f("wigglesPerSecond").max(0.0); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let (step, frac) = match wiggle { + 0 => (0u32, 0.0f32), + 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), + _ => { + let u = (ctx.time * wps).max(0.0); + (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) + } + }; + let rnd = |i: u32, k: u32| { + let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; + if frac > 0.0 { + let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; + a + (c - a) * frac + } else { + a + } + }; + let groups: Vec, bool, bool)>> = + if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; + // Per group a zig-zag polyline (in buffer px). + let mut lines: Vec>> = Vec::new(); + let mut runs = Vec::new(); + for (gi, g) in groups.iter().enumerate() { + let mut nmin = f64::INFINITY; + let mut nmax = f64::NEG_INFINITY; + for (pts, _, _) in g { + for q in pts { + let v = q[0] * n[0] + q[1] * n[1]; + nmin = nmin.min(v); + nmax = nmax.max(v); + } + } + nmin -= ew; + nmax += ew; + // Chains of zig-zags: each run joins the nearest chain that ended on the previous + // scan line (so separate regions get separate strokes). + let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); + let mut s = nmin + spacing * 0.5; + let mut li = 0u32; + let reach = spacing * 3.0 + ew; + while s < nmax && li < 20_000 { + scan_runs(g, fill, ew, d, n, s, &mut runs); + for (ri, &(a, bb)) in runs.iter().enumerate() { + let k = (gi as u32) << 20 | ri as u32; + let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); + let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); + let (a, bb) = (a - ov, bb + ov2); + if bb <= a { + continue; + } + let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; + let (pa, pb) = (at(a), at(bb)); + let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); + let best = chains + .iter() + .enumerate() + .filter(|(_, c)| li > 0 && c.1 == li - 1) + .map(|(i, c)| { + let last = *c.0.last().unwrap_or(&pa); + (i, dist(last, pa).min(dist(last, pb))) + }) + .filter(|&(_, dd)| dd < reach) + .min_by(|x, y| x.1.total_cmp(&y.1)); + match best { + Some((ci, _)) => { + let last = *chains[ci].0.last().unwrap_or(&pa); + let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; + // Curved turn: an extra point pushed past the edge. + let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); + let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; + let ch = &mut chains[ci]; + ch.0.extend([mid, p0, p1]); + ch.1 = li; + } + None => chains.push((vec![pa, pb], li)), + } + } + s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); + li += 1; + } + lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); + } + let r = width * 0.5; + let mut segs = Vec::new(); + for group in &lines { + let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); + let total: f64 = lens.iter().sum(); + let (g0, g1) = (total * start, total * end); + let mut acc = 0.0; + for (l, len) in group.iter().zip(lens) { + let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)); + acc += len; + poly_segs(&part, false, r, 1.0, &mut segs); + } + } + let cov = raster_segs(w, h, &segs, 0.9); + let color = pr.color("color"); + paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Stroke + +fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let all = pr.b("allMasks"); + let polys: Vec<_> = if all { all_masks_px(ctx, &b) } else { mask_px(ctx, &b, pr.f("path").round() as usize).into_iter().collect() }; + let style = pr.e("paintStyle"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let size = (pr.f("brushSize") * b.scale).max(0.1); + let r = size * 0.5; + let hard = pr.f("brushHardness") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let spacing = pr.f("spacing") / 100.0; + let sequential = pr.b("strokeSequentially"); + let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); + let total: f64 = lens.iter().sum(); + let mut segs = Vec::new(); + let mut acc = 0.0; + for ((pts, closed, _), &len) in polys.iter().zip(&lens) { + let (s0, s1) = if all && sequential { + let (g0, g1) = (total * start, total * end); + ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) + } else { + (len * start, len * end) + }; + acc += len; + if s1 <= s0 { + continue; + } + if spacing <= 0.5 { + let part = trim_poly(pts, *closed, s0, s1); + poly_segs(&part, false, r, 1.0, &mut segs); + } else { + let step = (size * spacing).max(0.5); + let mut s = s0; + while s <= s1 + 1e-9 && segs.len() < 200_000 { + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + segs.push(Seg { a: q, b: q, r, v: 1.0 }); + s += step; + } + } + } + let cov = raster_segs(w, h, &segs, hard); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Vegas + +/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). +fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { + use std::collections::HashMap; + let (w, h) = (pl.w, pl.h); + if w < 2 || h < 2 { + return Vec::new(); + } + // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. + let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; + let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; + let mut pos: HashMap = HashMap::new(); + let mut adj: HashMap> = HashMap::new(); + let v = |x: usize, y: usize| pl.data[y * w + x]; + let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; + for y in 0..h - 1 { + for x in 0..w - 1 { + let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); + let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; + if idx == 0 || idx == 15 { + continue; + } + // Edge points (pixel centres at +0.5). + let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); + let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); + let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); + let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); + let pairs: &[(usize, usize)] = match idx { + 1 | 14 => &[(3, 0)], + 2 | 13 => &[(0, 1)], + 3 | 12 => &[(3, 1)], + 4 | 11 => &[(1, 2)], + 5 => &[(3, 0), (1, 2)], + 6 | 9 => &[(0, 2)], + 7 | 8 => &[(3, 2)], + 10 => &[(0, 1), (2, 3)], + _ => &[], + }; + let e = [top, right, bottom, left]; + for &(i, j) in pairs { + pos.insert(e[i].0, e[i].1); + pos.insert(e[j].0, e[j].1); + adj.entry(e[i].0).or_default().push(e[j].0); + adj.entry(e[j].0).or_default().push(e[i].0); + } + } + } + let mut keys: Vec = adj.keys().copied().collect(); + keys.sort_unstable(); + // Start open chains at endpoints (degree 1) first, then closed loops. + keys.sort_by_key(|k| adj[k].len() != 1); + let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); + let mut out = Vec::new(); + for &k0 in &keys { + for &first in &adj[&k0] { + if used.contains(&(k0.min(first), k0.max(first))) { + continue; + } + let mut chain = vec![pos[&k0]]; + let (mut prev, mut cur) = (k0, first); + used.insert((prev.min(cur), prev.max(cur))); + let mut closed = false; + loop { + chain.push(pos[&cur]); + if cur == k0 { + closed = true; + chain.pop(); + break; + } + let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); + match next { + Some(n) => { + used.insert((cur.min(n), cur.max(n))); + prev = cur; + cur = n; + } + None => break, + } + } + if chain.len() >= 2 { + out.push((chain, closed)); + } + } + } + out +} + +fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let sc = b.scale; + let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { + mask_px(ctx, &b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() + } else { + let src = layer_or_self(ctx, &b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); + let ch = pr.e("channel"); + let inv = pr.b("invertInput"); + let mut pl = Plane::from_image(&src, |px| { + let (c, a) = unpremul(px); + let v = match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if inv { 1.0 - v } else { v } + }); + let blur = pr.f("preBlur") * sc; + if blur > 0.05 { + pl = gauss_plane(&pl, blur, blur); + } + let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); + // Simplify by tolerance (drop points closer than tol × size). + let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; + if tol > 0.0 { + for (c, _) in cs.iter_mut() { + let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); + for &q in c.iter() { + if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { + keep.push(q); + } + } + *c = keep; + } + } + cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); + if pr.e("renderMode") == 1 { + let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; + cs.into_iter().skip(sel).take(1).collect() + } else { + cs + } + }; + let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; + let seg_len = pr.f("length").clamp(0.0, 1.0); + let bunched = pr.e("segmentDistribution") == 0; + let rot = pr.f("rotation") / 360.0; + let random_phase = pr.b("randomPhase"); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let width = (pr.f("width") * sc).max(0.1); + let hard = pr.f("hardness").clamp(0.0, 1.0); + let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); + let mut segs = Vec::new(); + for (ci, (pts, closed)) in paths.iter().enumerate() { + let len = poly_length(pts, *closed); + if len <= 0.0 { + continue; + } + let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; + let each = len / nseg as f64 * seg_len; + for i in 0..nseg { + let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; + let s0 = (base + phase).rem_euclid(1.0) * len; + // Sub-sample the segment so opacity can ramp along it. + let sub = 12usize; + let mut prev: Option<[f64; 2]> = None; + for k in 0..=sub { + let f = k as f64 / sub as f64; + let mut s = s0 + each * f; + if *closed { + s = s.rem_euclid(len); + } else if s > len { + break; + } + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; + if let Some(pv) = prev + && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 + { + segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); + } + prev = Some(q); + } + } + } + let cov = raster_segs(w, h, &segs, hard); + let color = pr.color("color"); + match pr.e("blendMode") { + 1 => paint(&mut b.img, &cov, color, 1.0, 0), + 2 => { + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = c * color[3]; + *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); + }); + } + 3 => paint(&mut b.img, &cov, color, 1.0, 2), + _ => paint(&mut b.img, &cov, color, 1.0, 1), + } + b +} + +// ---------------------------------------------------------------- Write-on / Glue Gun + +/// Brush dab history: only the brush position at the current time is known to an effect +/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along +/// the current position — a single dab when the brush is static. Deterministic. +fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let q = b.to_px(pr.v2("brushPosition")); + let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let cov = raster_segs(w, h, &[Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], pr.f("brushHardness") / 100.0); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("brushOpacity") / 100.0) as f32, pr.e("paintStyle")); + b +} + +fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let q = b.to_px(pr.v2("brushPosition")); + let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); + let density = pr.f("density").max(0.0); + let refl = pr.f("reflection").to_radians(); + let strength = (pr.f("strength") / 100.0) as f32; + let glue_only = pr.e("style") == 1; + let src = b.img.clone(); + let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; + let beads = (density.round() as usize).clamp(1, 32); + let seed = ctx.seed; + let centers: Vec<([f64; 2], f64)> = (0..beads) + .map(|i| { + let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; + let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; + ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + // Height field: union of hemispheres. + let mut best = (0.0f64, [0.0f64; 2], 1.0f64); + for &(c, r) in ¢ers { + let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); + let d2 = dx * dx + dy * dy; + if d2 < 1.0 { + let hgt = (1.0 - d2).sqrt(); + if hgt > best.0 { + best = (hgt, [dx, dy], r); + } + } + } + if best.0 <= 0.0 { + if glue_only { + *px = [0.0; 4]; + } + continue; + } + let nrm = [best.1[0], best.1[1], best.0]; + let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); + let spec = ndl.powi(24) as f32; + // Refraction: look through the bead. + let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); + let edge = smoothstep(0.0, 0.15, best.0 as f32); + let shade = 0.65 + 0.35 * ndl as f32; + let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; + let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; + let base = if glue_only { [0.0; 4] } else { *px }; + *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); + px[3] = px[3].clamp(0.0, 1.0); + } + }); + b +} + +// ---------------------------------------------------------------- Paint Bucket + +fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return b; + } + let fp = b.to_px(pr.v2("fillPoint")); + let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); + let sel = pr.e("fillSelector"); + let tol = (pr.f("tolerance") / 255.0) as f32; + let key = |p: Px| -> [f32; 4] { + let (c, a) = unpremul(p); + match sel { + 1 => [c[0], c[1], c[2], 0.0], + 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], + 3 | 4 => [0.0, 0.0, 0.0, a], + _ => p, + } + }; + let mut region = Plane::new(w, h); + if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { + let seed = key(b.img.data[sy as usize * w + sx as usize]); + let within = |i: usize| { + let k = key(b.img.data[i]); + if sel == 2 { + return k[3] == seed[3]; + } + (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) + }; + let mut seen = vec![false; w * h]; + let mut stack = vec![(sx as usize, sy as usize)]; + while let Some((x, y)) = stack.pop() { + let row = y * w; + if seen[row + x] || !within(row + x) { + continue; + } + let mut l = x; + while l > 0 && !seen[row + l - 1] && within(row + l - 1) { + l -= 1; + } + let mut r = x; + while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { + r += 1; + } + for i in l..=r { + seen[row + i] = true; + region.data[row + i] = 1.0; + if y > 0 && !seen[row - w + i] { + stack.push((i, y - 1)); + } + if y + 1 < h && !seen[row + w + i] { + stack.push((i, y + 1)); + } + } + } + } + if pr.b("viewThreshold") { + b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); + return b; + } + let sc = b.scale; + let mut cov = match pr.e("stroke") { + 1 => { + let s = pr.f("featherSoftness") * sc * 0.5; + if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } + } + 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), + 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), + 4 => { + let r = pr.f("strokeWidth") * sc; + let out = morph_frac(®ion, r * 0.5, true); + let inn = morph_frac(®ion, r * 0.5, false); + out.zip_map(&inn, |a, b| (a - b).max(0.0)) + } + _ => gauss_plane(®ion, 0.5, 0.5), + }; + if pr.e("stroke") == 0 { + // Antialias: keep the hard interior, soften only the boundary. + cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); + } + let color = pr.color("color"); + let op = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let k = (c * op * color[3]).clamp(0.0, 1.0); + if k <= 0.0 { + if mode == 9 { + *px = [0.0; 4]; + } + return; + } + let (dc, da) = unpremul(*px); + let fc = [color[0], color[1], color[2]]; + let mixed = match mode { + 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], + 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], + 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], + 4..=7 => { + let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); + let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); + let (r, g, bl) = match mode { + 4 => hsl_to_rgb(fh, ds, dl), + 5 => hsl_to_rgb(dh, fs, dl), + 6 => hsl_to_rgb(fh, fs, dl), + _ => hsl_to_rgb(dh, ds, fl), + }; + [r, g, bl] + } + _ => fc, + }; + *px = match mode { + 8 => over(premul(fc, k), *px), + 9 => premul(fc, k), + 0 => over(*px, premul(fc, k)), + _ => { + if da <= 1e-6 { + return; + } + let c3 = crate::util::lerp3(dc, mixed, k); + premul(c3, da) + } + }; + }); + b +} + +// ---------------------------------------------------------------- Eyedropper Fill + +fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as i64, b.img.height as i64); + let c = b.to_px(pr.v2("samplePoint")); + let r = (pr.f("sampleRadius") * b.scale).max(0.0); + let mode = pr.e("averagePixelColors"); + let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); + let ri = r.ceil() as i64; + let mut acc = [0.0f64; 4]; + let mut n = 0.0f64; + for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { + for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { + if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { + continue; + } + let px = b.img.data[(y * w + x) as usize]; + match mode { + 0 => { + if px[3] > 1e-4 { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + } + 1 => { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + 2 => { + for k in 0..3 { + acc[k] += px[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + _ => { + for k in 0..4 { + acc[k] += px[k] as f64; + } + n += 1.0; + } + } + } + } + let fill: Px = if n > 0.0 { + if mode == 3 { + let a = (acc[3] / n) as f32; + let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; + [s[0], s[1], s[2], a] + } else { + [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] + } + } else { + [0.0, 0.0, 0.0, 0.0] + }; + let keep_alpha = pr.b("maintainOriginalAlpha"); + let blend = (pr.f("blendWithOriginal") / 100.0) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let a = if keep_alpha { px[3] } else { fill[3] }; + let f = premul([fill[0], fill[1], fill[2]], a); + *px = crate::util::lerp4(f, *px, blend); + }); + b +} + +// ---------------------------------------------------------------- CC Threads + +fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let sc = b.scale; + let tw = (pr.f("width") * sc).max(1.0); + let th = (pr.f("height") * sc).max(1.0); + let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; + let ang = pr.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let c = b.to_px(pr.v2("center")); + let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); + let shadow = (pr.f("shadowing") / 100.0) as f32; + let texture = (pr.f("texture") / 100.0) as f32; + let src = b.img.clone(); + let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); + 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 u = dx * cs + dy * sn; + let v = -dx * sn + dy * cs; + let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); + let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); + // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the + // band |fu-0.5| < coverage/2. + let in_h = (fv - 0.5).abs() < coverage * 0.5; + let in_v = (fu - 0.5).abs() < coverage * 0.5; + // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. + let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; + let which = match (in_h, in_v) { + (true, true) => Some(h_top), + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }; + let Some(horizontal) = which else { + *px = [0.0; 4]; + continue; + }; + // Thread colour: the source sampled along the thread centre line. + let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; + let s = src.sample_bilinear_clamped(sx, sy); + // Round profile across the thread + darkening where it dives under its neighbour. + let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; + let along = if horizontal { fu } else { fv }; + let profile = (1.0 - across * across).max(0.0).sqrt() as f32; + let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; + let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; + let fib = + if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; + let k = (shade * fib).max(0.0); + *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; + } + }); + b +} + +// ---------------------------------------------------------------- Audio Spectrum / Waveform + +/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). +pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { + let n = re.len(); + if n < 2 { + return; + } + let mut j = 0; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let ang = -2.0 * PI / len as f64; + let (ws, wc) = ang.sin_cos(); + for start in (0..n).step_by(len) { + let (mut cr, mut ci) = (1.0, 0.0); + for k in 0..len / 2 { + let (a, bb) = (start + k, start + k + len / 2); + let tr = re[bb] * cr - im[bb] * ci; + let ti = re[bb] * ci + im[bb] * cr; + re[bb] = re[a] - tr; + im[bb] = im[a] - ti; + re[a] += tr; + im[a] += ti; + let ncr = cr * wc - ci * ws; + ci = cr * ws + ci * wc; + cr = ncr; + } + } + len <<= 1; + } +} + +const AUDIO_RATE: u32 = 48_000; + +/// Fetch mono (or one channel) samples of the audio layer around the current time. +fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { + let id = ctx.params.get("audioLayer")?.as_layer()?; + let host = ctx.env.host?; + let dur = (dur_ms / 1000.0).max(0.001); + let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); + let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; + let s = host.audio(id, start, frames, AUDIO_RATE)?; + Some( + (0..frames) + .map(|i| { + let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); + match channel { + 1 => l, + 2 => r, + _ => (l + r) * 0.5, + } + }) + .collect(), + ) +} + +/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at +/// parameter u∈[0,1] → (point, unit normal). +struct Baseline { + pts: Vec<[f64; 2]>, + closed: bool, + len: f64, + polar: bool, + center: [f64; 2], + radius: f64, +} + +impl Baseline { + fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { + let pr = ctx.params; + let polar = pr.b("usePolarPath"); + let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { + Some((p, c, _)) if !polar => (p, c), + _ => { + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + (vec![[s.0, s.1], [e.0, e.1]], false) + } + }; + let len = poly_length(&pts, closed); + let center = pts[0]; + let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; + Baseline { pts, closed, len, polar, center, radius } + } + fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { + if self.polar { + let a = u * 2.0 * PI - PI * 0.5; + let (s, c) = a.sin_cos(); + return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); + } + let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); + (q, [d[1], -d[0]]) + } +} + +/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). +fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let core = raster_segs(w, h, segs_core, 1.0); + let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); + let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, hl) = (core.data[i], halo.data[i]); + let a = c.max(hl); + let base = if composite { *px } else { [0.0; 4] }; + if a <= 0.0 { + *px = base; + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); + *px = over(base, premul(col, a)); + }); +} + +fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0) else { return b }; + let n = samples.len().next_power_of_two().max(64); + let mut re = vec![0.0; n]; + let mut im = vec![0.0; n]; + let m = samples.len(); + for (i, s) in samples.iter().enumerate() { + // Hann window. + re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos()); + } + fft(&mut re, &mut im); + let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; + let f0 = pr.f("startFrequency").max(1.0); + let f1 = pr.f("endFrequency").max(f0 + 1.0); + let hz_per_bin = AUDIO_RATE as f64 / n as f64; + let mag = |f: f64| { + let k = ((f / hz_per_bin).round() as usize).min(n / 2); + (re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64 + }; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let side = pr.e("sideOptions"); + let base = Baseline::new(ctx, &b); + let mut tops = Vec::with_capacity(bands); + let mut segs = Vec::new(); + for i in 0..bands { + let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; + let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64); + let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64); + let mut v = 0.0f64; + let k = 4; + for j in 0..k { + v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64)); + } + let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0; + let hgt = db.clamp(0.0, 1.0) * max_h; + let (q, nrm) = base.at(u); + let (ua, ub) = match side { + 0 => (0.0, hgt), + 1 => (-hgt, 0.0), + _ => (-hgt, hgt), + }; + let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; + let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; + match disp { + 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), + _ => tops.push((pa, pb)), + } + } + if disp == 1 { + for w2 in tops.windows(2) { + segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); + } + } + } else if disp == 2 { + for &(pa, pb) in &tops { + segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); + } + } + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions")) else { return b }; + let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let base = Baseline::new(ctx, &b); + let m = samples.len(); + let mut pts = Vec::with_capacity(count); + let mut segs = Vec::new(); + for i in 0..count { + let u = i as f64 / (count - 1) as f64; + let a = ((u * (m - 1) as f64) as usize).min(m - 1); + let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); + // Peak with sign of the window for a stable digital display. + let (mut lo, mut hi) = (0.0f64, 0.0f64); + for s in &samples[a..=bnd] { + lo = lo.min(*s); + hi = hi.max(*s); + } + let (q, nrm) = base.at(u); + match disp { + 0 => segs.push(Seg { + a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], + b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], + r: thick * 0.5, + v: 1.0, + }), + _ => { + let v = samples[a] * max_h; + pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); + } + } + } + if disp == 1 { + poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); + } else if disp == 2 { + segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); + let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), slider(0.0, 32.0, 0.0, 8.0, 0)); + let display = || popup(&["Digital", "Analog lines", "Analog dots"]); + vec![ + spec( + "ec.generate.fractal", + "Fractal", + vec![ + p( + "setChoice", + "Set Choice", + Value::Enum(0), + popup(&["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Inverse", "Mandelbrot Inverse Over Mandelbrot", "Julia", "Julia Inverse"]), + ), + p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), + p("mandelbrotX", "Mandelbrot: X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotY", "Mandelbrot: Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotMagnification", "Mandelbrot: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("mandelbrotEscapeLimit", "Mandelbrot: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("juliaX", "Julia: X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaY", "Julia: Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaMagnification", "Julia: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("juliaEscapeLimit", "Julia: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("postInversionX", "Post-Inversion Offset: X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("postInversionY", "Post-Inversion Offset: Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + check("overlay", "Overlay", false), + check("transparency", "Transparency", false), + p("palette", "Palette", Value::Enum(0), popup(&["Lightness Bands", "Hue Bands", "Hue/Lightness Bands"])), + p("hue", "Hue", num(0.0), ang()), + p("cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), + p("cycleOffset", "Cycle Offset", num(0.0), ang()), + check("edgeHighlight", "Edge Highlight", false), + p("samplingMethod", "Sampling Method", Value::Enum(0), popup(&["Edge Detect", "Every Pixel"])), + p("samplingFactor", "Sampling Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), + ], + fractal, + ), + spec( + "ec.generate.scribble", + "Scribble", + vec![ + p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), + mask_idx("mask", "Mask", 1.0), + p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), + p("edgeWidth", "Edge Options: Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("angle", "Angle", num(45.0), ang()), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), + p("curviness", "Stroke Options: Curviness", num(50.0), pct()), + p("curvinessVariation", "Stroke Options: Curviness Variation", num(50.0), pct()), + p("spacing", "Stroke Options: Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), + p("spacingVariation", "Stroke Options: Spacing Variation", num(50.0), pct()), + p("pathOverlap", "Stroke Options: Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pathOverlapVariation", "Stroke Options: Path Overlap Variation", num(50.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + check("fillPathsSequentially", "Fill Paths Sequentially", true), + p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), + p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), + ], + scribble, + ), + spec( + "ec.generate.stroke", + "Stroke", + vec![ + mask_idx("path", "Path", 1.0), + check("allMasks", "All Masks", false), + check("strokeSequentially", "Stroke Sequentially", true), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("opacity", "Opacity", num(100.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + stroke, + ), + spec( + "ec.generate.vegas", + "Vegas", + vec![ + p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), + p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), + check("invertInput", "Image Contours: Invert Input", false), + p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), + p("threshold", "Image Contours: Threshold", num(50.0), pct()), + p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), + p("renderMode", "Image Contours: Render Mode", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), + p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), + mask_idx("path", "Mask/Path: Path", 1.0), + p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), + p("rotation", "Segments: Rotation", num(0.0), ang()), + check("randomPhase", "Segments: Random Phase", false), + p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), + p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + ], + vegas, + ), + spec( + "ec.generate.writeon", + "Write-on", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("brushOpacity", "Brush Opacity", num(100.0), pct()), + p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), + p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), + p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), + p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + write_on, + ), + spec( + "ec.generate.paintbucket", + "Paint Bucket", + vec![ + p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), + p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), + p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), + check("viewThreshold", "View Threshold", false), + p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), + p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p( + "blendingMode", + "Blending Mode", + Value::Enum(0), + popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), + ), + ], + paint_bucket, + ), + spec( + "ec.generate.eyedropperfill", + "Eyedropper Fill", + vec![ + p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), + p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), + check("maintainOriginalAlpha", "Maintain Original Alpha", false), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + eyedropper_fill, + ), + spec( + "ec.generate.ccgluegun", + "CC Glue Gun", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), + p("reflection", "Reflection", num(45.0), ang()), + p("strength", "Strength", num(100.0), pct()), + p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), + ], + glue_gun, + ), + spec( + "ec.generate.ccthreads", + "CC Threads", + vec![ + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), + p("direction", "Direction", num(0.0), ang()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("coverage", "Coverage", num(80.0), pct()), + p("shadowing", "Shadowing", num(50.0), pct()), + p("texture", "Texture", num(10.0), pct()), + ], + threads, + ), + spec( + "ec.generate.audiospectrum", + "Audio Spectrum", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), + p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), + p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), + p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(50.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + check("blendOverlappingColors", "Blend Overlapping Colors", true), + p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), + check("dynamicHuePhase", "Dynamic Hue Phase", false), + check("colorSymmetry", "Color Symmetry", false), + p("displayOptions", "Display Options", Value::Enum(0), display()), + p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), + check("durationAveraging", "Duration Averaging", false), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_spectrum, + ), + spec( + "ec.generate.audiowaveform", + "Audio Waveform", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), + p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(10.0), pct()), + p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), + p("displayOptions", "Display Options", Value::Enum(1), display()), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_waveform, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; + + /// Run with point defaults scaled to the layer size. + pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.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}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + run_env(id, set, img, 0.5, EffectEnv::default()) + } + + fn square_mask() -> Vec { + vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] + } + + fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.data[y * img.width as usize + x][3]; + } + } + s + } + + #[test] + fn fractal_deterministic_and_has_inside_set() { + let img = Image::new(64, 48); + let a = run("ec.generate.fractal", &[], img.clone()); + let b = run("ec.generate.fractal", &[], img.clone()); + assert_eq!(a.img.data, b.img.data); + // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. + assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); + assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); + let t = run("ec.generate.fractal", &[("transparency", Value::Bool(true))], img); + assert!(t.img.data.iter().any(|p| p[3] == 0.0)); + } + + #[test] + fn scribble_fills_mask_and_animates() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); + let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + assert_eq!(a.img.data, b.img.data); + let inside = alpha_sum(&a.img, 18, 18, 46, 46); + let outside = alpha_sum(&a.img, 0, 0, 10, 64); + assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); + let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); + assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); + let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); + let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); + assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); + let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); + assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); + } + + #[test] + fn scribble_edge_types() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeWidth", num(6.0))], img, 0.5, env); + // Outside edge: paint near the border outside, nothing in the centre. + assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); + assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); + } + + #[test] + fn stroke_draws_along_mask() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); + assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); + assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); + let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); + assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); + let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); + assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); + // No masks: unchanged. + let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); + assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); + } + + #[test] + fn vegas_traces_contours() { + let mut img = Image::new(64, 64); + for y in 16..48 { + for x in 16..48 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let a = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + let b = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + assert_eq!(a.img.data, b.img.data); + assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); + assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); + let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); + assert_ne!(r.img.data, a.img.data); + } + + #[test] + fn contours_of_square_are_closed() { + let mut pl = Plane::new(20, 20); + for y in 5..15 { + for x in 5..15 { + pl.data[y * 20 + x] = 1.0; + } + } + let c = contours(&pl, 0.5); + assert_eq!(c.len(), 1); + assert!(c[0].1); + let l = poly_length(&c[0].0, true); + assert!((l - 40.0).abs() < 4.0, "{l}"); + } + + #[test] + fn write_on_and_glue_gun_draw_at_brush() { + let img = Image::new(32, 32); + let a = run("ec.generate.writeon", &[], img.clone()); + assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); + assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); + let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); + let g = run("ec.generate.ccgluegun", &[], src.clone()); + assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); + assert_ne!(g.img.get(16, 16), src.get(16, 16)); + assert_eq!(g.img.get(0, 0), src.get(0, 0)); + } + + #[test] + fn paint_bucket_fills_enclosed_region_only() { + let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); + for i in 8..24 { + for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { + img.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + let out = run("ec.generate.paintbucket", &[], img.clone()); + let inside = out.img.get(16, 16); + assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); + assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); + assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); + assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); + } + + #[test] + fn eyedropper_fill_uses_sample() { + let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); + img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.generate.eyedropperfill", &[], img.clone()); + assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); + let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); + assert_eq!(blended.img.data, img.data); + } + + #[test] + fn threads_weave_is_deterministic_and_gappy() { + let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); + let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); + assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); + assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); + assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); + } + + struct Tone; + impl EffectHost for Tone { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { + Some( + (0..frames) + .flat_map(|i| { + let t = start + i as f64 / rate as f64; + let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; + [v, v] + }) + .collect(), + ) + } + } + + #[test] + fn audio_effects_without_audio_pass_through() { + let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); + } + } + + #[test] + fn audio_effects_draw_with_host() { + let host = Tone; + let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; + let img = Image::new(96, 64); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + assert_eq!(a.img.data, b.img.data, "{id}"); + assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); + } + } + + #[test] + fn fft_finds_tone() { + let n = 256; + let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); + let mut im = vec![0.0; n]; + fft(&mut re, &mut im); + let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); + let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + assert_eq!(peak, 8); + } }