From b05467e47fc6c5b2ca33908b40c437fb64df4d07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:34:41 +0000 Subject: [PATCH] refactor(effects): split generate3.rs into generate3/*.rs --- crates/effects/src/generate3.rs | 2040 +--------------------- crates/effects/src/generate3/audio.rs | 314 ++++ crates/effects/src/generate3/common.rs | 13 + crates/effects/src/generate3/fill.rs | 334 ++++ crates/effects/src/generate3/fractal.rs | 193 ++ crates/effects/src/generate3/paths.rs | 553 ++++++ crates/effects/src/generate3/raster.rs | 369 ++++ crates/effects/src/generate3/registry.rs | 265 +++ 8 files changed, 2061 insertions(+), 2020 deletions(-) create mode 100644 crates/effects/src/generate3/audio.rs create mode 100644 crates/effects/src/generate3/common.rs create mode 100644 crates/effects/src/generate3/fill.rs create mode 100644 crates/effects/src/generate3/fractal.rs create mode 100644 crates/effects/src/generate3/paths.rs create mode 100644 crates/effects/src/generate3/raster.rs create mode 100644 crates/effects/src/generate3/registry.rs diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 4d2cf649..a61c3f76 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -17,2026 +17,26 @@ 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, Params, 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 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 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), - }; - }); -} - -/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs` -/// with `hardness`, or the union of convex polygons (`raster_convex`). -#[derive(Clone, Debug)] -pub enum Coverage { - Segs { segs: Vec, hardness: f64 }, - Convex(Vec>), -} - -impl Coverage { - fn raster(&self, w: usize, h: usize) -> Plane { - match self { - Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness), - Coverage::Convex(polys) => raster_convex(w, h, polys), - } - } -} - -/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu -/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original -/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). -#[derive(Clone, Debug)] -pub struct PaintPlan { - pub cov: Coverage, - pub color: [f32; 4], - pub opacity: f32, - pub style: u32, -} - -/// Draw a [`PaintPlan`]. -fn draw_paint(b: &mut Buf, plan: &PaintPlan) { - let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize); - if plan.style == 3 { - let color = plan.color; - 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); - }); - } else { - paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style); - } -} - -/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it. -pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool { - match id { - // Image Contours read the layer itself when no (resolvable) Input Layer is chosen. - "ec.generate.vegas" => ctx.params.e("stroke") == 0, - "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true, - "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0, - _ => false, - } -} - -/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read -/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is -/// cleared when it composites On Transparent, else kept). -pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.stroke" => Some(stroke_plan(ctx, b)), - "ec.generate.scribble" => scribble_plan(ctx, b), - "ec.generate.vegas" => Some(vegas_plan(ctx, b)), - "ec.generate.writeon" => Some(write_on_plan(ctx, b)), - _ => None, - } -} - -/// Scribble's Composite as a `paint` style. -pub fn scribble_style(ctx: &EffectCtx) -> u32 { - match ctx.params.e("composite") { - 0 => 0, - 2 => 2, - _ => 1, - } -} - -#[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 { - /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices). - over_julia: bool, - 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 -} - -/// Fractal Set Choice options. -pub(crate) const FRACTAL_SETS: [&str; 6] = - ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"]; -/// Fractal Palette options. -pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"]; -/// Fractal Oversample Method options. -pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"]; - -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("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY")); - let mmag = pr.f("mandelbrot/mandelbrotMagnification"); - let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01); - let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY")); - let jmag = pr.f("julia/juliaMagnification"); - let jlim = pr.f("julia/juliaEscapeLimit").max(1.01); - let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY")); - let overlay = pr.b("fractalColor/overlay"); - let transparency = pr.b("fractalColor/transparency"); - let transparent_inside = transparency || overlay; - let palette = pr.e("fractalColor/palette"); - let hue = (pr.f("fractalColor/hue") / 360.0) as f32; - let steps = pr.f("fractalColor/cycleSteps").max(1.0); - let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0; - let edge = pr.b("fractalColor/edgeHighlight"); - let every = pr.e("highQualitySettings/samplingMethod") == 1; - let factor = pr.f("highQualitySettings/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]); - // FRACTAL_SETS order. - let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse }; - let sets: (FracSet, Option) = match set_choice { - 1 => (set(false, true, false), None), - 2 => (set(false, false, true), None), - 3 => (set(false, true, true), None), - 4 => (set(true, false, false), None), - 5 => (set(true, true, false), None), - _ => (set(false, false, 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 { - let z0 = if s.over_julia { jc } else { (0.0, 0.0) }; - escape(z0, (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 { - // Solid Color: only the inside of the set is drawn (Transparency flips the sides). - if palette == 3 { - let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5); - return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] }; - } - let Some(n) = n else { - return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; - }; - let u = n / steps + cyc_off; - let band = (u.rem_euclid(1.0)) as f32; - let (r, g, bl) = match palette { - // Hue Wheel: the full hue circle at full saturation and brightness. - 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), - // Black And White: alternating bands. - 2 => { - let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 }; - (v, v, v) - } - // Lightness Gradient: black → hue → white, the hue moving 45° each cycle. - _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 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; -} - -/// End Cap and Join options (Scribble's Edge Options). -const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; -const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; - -/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: -/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the -/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). -pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { - let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { - (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() - }; - let mut out = Vec::new(); - for l in lines { - let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { - if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { - v.push(p); - } - v - }); - if pts.len() < 2 { - if let (Some(p), 0) = (pts.first(), cap) { - out.push(disc(*p)); - } - continue; - } - let dir = |a: [f64; 2], b: [f64; 2]| { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let l = dx.hypot(dy).max(1e-12); - [dx / l, dy / l] - }; - let n = pts.len(); - for k in 0..n - 1 { - let (mut a, mut b) = (pts[k], pts[k + 1]); - let d = dir(a, b); - // Projecting caps extend the end segments by the half width. - if cap == 2 && k == 0 { - a = [a[0] - d[0] * r, a[1] - d[1] * r]; - } - if cap == 2 && k == n - 2 { - b = [b[0] + d[0] * r, b[1] + d[1] * r]; - } - let nn = [-d[1] * r, d[0] * r]; - out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); - } - // Joins. - for k in 1..n - 1 { - let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); - let cross = d0[0] * d1[1] - d0[1] * d1[0]; - if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { - continue; - } - if join == 0 { - out.push(disc(p)); - continue; - } - // The outer side of the turn. - let s = if cross > 0.0 { -1.0 } else { 1.0 }; - let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; - let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; - let bevel = vec![p, o0, o1]; - if join == 1 { - // Miter point: where the two offset edges meet. - let bis = [d0[0] - d1[0], d0[1] - d1[1]]; - let bl = bis[0].hypot(bis[1]); - let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); - let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; - if bl > 1e-9 && len <= miter_limit * r { - let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; - let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); - let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; - out.push(vec![p, o0, tip, o1]); - continue; - } - } - out.push(bevel); - } - if cap == 0 { - out.push(disc(pts[0])); - out.push(disc(pts[n - 1])); - } - } - out -} - -/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. -pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { - let mut out = Plane::new(w, h); - let inside = |poly: &[[f64; 2]], x: f64, y: f64| { - let n = poly.len(); - let mut sign = 0.0; - for i in 0..n { - let (a, b) = (poly[i], poly[(i + 1) % n]); - let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); - if c.abs() < 1e-12 { - continue; - } - if sign == 0.0 { - sign = c.signum(); - } else if c.signum() != sign { - return false; - } - } - true - }; - for poly in polys { - if poly.len() < 3 { - continue; - } - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in poly { - x0 = x0.min(p[0]); - y0 = y0.min(p[1]); - x1 = x1.max(p[0]); - y1 = y1.max(p[1]); - } - let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); - let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); - for y in ya..yb { - for x in xa..xb { - let mut hit = 0; - for sy in 0..4 { - for sx in 0..4 { - if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { - hit += 1; - } - } - } - let v = &mut out.data[y * w + x]; - *v = v.max(hit as f32 / 16.0); - } - } - } - out -} - -fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { - match scribble_plan(ctx, &b) { - Some(plan) => draw_paint(&mut b, &plan), - None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height), - None => {} - } - b -} - -fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option { - 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 = scribble_style(ctx); - if polys.is_empty() { - return None; - } - let sc = b.scale; - let fill = pr.e("fillType"); - let ew = pr.f("edgeOptions/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("strokeOptions/curviness") / 100.0; - let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0; - let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25); - let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0; - let overlap = pr.f("strokeOptions/pathOverlap") / 100.0; - let overlap_var = pr.f("strokeOptions/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; - // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. - let each = pr.e("startEndApplyTo") == 1; - let mut parts: Vec> = 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 = - if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; - acc += len; - parts.push(part); - } - } - let cap = pr.e("edgeOptions/endCap"); - let join = pr.e("edgeOptions/join"); - let cov = if cap == 0 && join == 0 { - let mut segs = Vec::new(); - for part in &parts { - poly_segs(part, false, r, 1.0, &mut segs); - } - Coverage::Segs { segs, hardness: 0.9 } - } else { - Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0))) - }; - Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }) -} - -// ---------------------------------------------------------------- Stroke - -fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = stroke_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - 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 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; - } - } - } - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style } -} - -// ---------------------------------------------------------------- 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 plan = vegas_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - 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; - // Shorter Contours Have Fewer Segments: segment count scales with contour length. - let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1; - let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max); - 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 nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg }; - 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 style = match pr.e("blendMode") { - 1 => 0, - 2 => 3, - 3 => 2, - _ => 1, - }; - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style } -} - -// ---------------------------------------------------------------- Write-on / Glue Gun - -/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every -/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length -/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; -/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own -/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per -/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. -fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = write_on_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. -const WRITE_ON_MAX_DABS: i64 = 4096; - -fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let own_opacity = pr.e("paintTimeProperties") == 1; - let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); - let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; - let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); - let dab = |p: &Params| { - let q = b.to_px(p.v2("brushPosition")); - ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) - }; - let mut segs = vec![]; - let t = ctx.time; - let spacing = pr.f("brushSpacing").max(0.001); - let life = pr.f("strokeLength").max(0.0); - if let Some(host) = ctx.env.host - && t.is_finite() - && t >= 0.0 - { - let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; - let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; - let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; - let n = k1.saturating_sub(k0).saturating_add(1); - let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); - let mut prev: Option<[f64; 2]> = None; - let mut k = k0; - while k <= k1 { - let tk = k as f64 * spacing; - let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; - if let Some((q, r, v)) = at { - // Dabs laid further apart than the brush spacing are joined into a line. - let a = if stride > 1 { prev.unwrap_or(q) } else { q }; - segs.push(Seg { a, b: q, r, v }); - prev = Some(q); - } - k = k.saturating_add(stride); - } - } - if segs.is_empty() { - let (q, r, v) = dab(pr); - segs.push(Seg { a: q, b: q, r, v }); - } - PaintPlan { - cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, - color: pr.color("color"), - opacity: if own_opacity { 1.0 } else { opacity(pr) }, - style: pr.e("paintStyle"), - } -} - -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 - -/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the -/// layer's pixels (shared with the GPU compositor, which reads the layer back for it). -pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - 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)); - } - } - } - } - region -} - -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 region = paint_bucket_region(ctx, &b); - 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 fill = eyedropper_color(ctx, &b); - 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 -} - -/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with -/// the GPU compositor, which reads the layer back for it). -pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px { - 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; - } - } - } - } - 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] - } -} - -// ---------------------------------------------------------------- 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)); - }); -} - -/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): -/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them. -#[derive(Clone, Debug)] -pub struct MarksPlan { - pub segs: Vec, - pub thickness: f64, - pub softness: f64, - pub inside: [f32; 4], - pub outside: [f32; 4], - pub composite: bool, -} - -/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio: -/// the layer passes through). -pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.audiospectrum" => spectrum_plan(ctx, b), - "ec.generate.audiowaveform" => waveform_plan(ctx, b), - _ => None, - } -} - -fn marks(ctx: &EffectCtx, segs: Vec, thick: f64, soft: f64) -> MarksPlan { - let pr = ctx.params; - MarksPlan { - segs, - thickness: thick, - softness: soft * 2.0, - inside: pr.color("insideColor"), - outside: pr.color("outsideColor"), - composite: pr.b("compositeOnOriginal"), - } -} - -fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = spectrum_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = waveform_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec) { - let m = samples.len(); - let n = m.next_power_of_two().max(64); - let mut re = vec![0.0; n]; - let mut im = vec![0.0; n]; - let denom = (m.max(2) - 1) as f64; - for (i, s) in samples.iter().enumerate() { - let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos(); - if let Some(r) = re.get_mut(i) { - *r = s * w; - } - } - fft(&mut re, &mut im); - let hz_per_bin = AUDIO_RATE as f64 / n as f64; - let scale = 4.0 / m.max(1) as f64; - let num_bins = n / 2 + 1; - let mut mags = Vec::with_capacity(num_bins); - for k in 0..num_bins { - let r = re.get(k).copied().unwrap_or(0.0); - let im_val = im.get(k).copied().unwrap_or(0.0); - let mag = (r * r + im_val * im_val).sqrt() * scale; - mags.push(mag); - } - (n, hz_per_bin, mags) -} - -fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?; - let (_n, hz_per_bin, mags) = compute_fft_mags(&samples); - - let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; - let f0 = pr.f("startFrequency").max(0.0); - let f1 = pr.f("endFrequency").max(f0 + 1.0); - let max_bin = mags.len().saturating_sub(1); - - let get_band_mag = |fc: f64| -> f64 { - if mags.is_empty() || hz_per_bin <= 0.0 { - return 0.0; - } - let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64); - let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1)); - let k1 = (k0 + 1).min(max_bin); - let frac = (k_float - k0 as f64).clamp(0.0, 1.0); - let m0 = mags.get(k0).copied().unwrap_or(0.0); - let m1 = mags.get(k1).copied().unwrap_or(0.0); - m0 * (1.0 - frac) + m1 * frac - }; - - 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 fc = f0 + (f1 - f0) * u; - let v = get_band_mag(fc); - let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 }; - 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 }); - } - } - } - Some(marks(ctx, segs, thick, soft)) -} - -fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?; - 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 })); - } - Some(marks(ctx, segs, thick, soft)) -} - -// ---------------------------------------------------------------- registry - -pub fn specs() -> Vec { - 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), ParamUi::Mask); - let display = || popup(&["Digital", "Analog lines", "Analog dots"]); - vec![ - spec( - "ec.generate.fractal", - "Fractal", - vec![ - p("setChoice", "Set Choice", Value::Enum(0), popup(&FRACTAL_SETS)), - p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), - p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - check("fractalColor/overlay", "Overlay", false), - check("fractalColor/transparency", "Transparency", false), - p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)), - p("fractalColor/hue", "Hue", num(0.0), ang()), - p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), - p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()), - check("fractalColor/edgeHighlight", "Edge Highlight", false), - p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)), - p("highQualitySettings/samplingFactor", "Oversample 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("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), - p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), - p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.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("strokeOptions/curviness", "Curviness", num(50.0), pct()), - p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()), - p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), - p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()), - p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), - p("start", "Start", num(0.0), pct()), - p("end", "End", num(100.0), pct()), - p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), - 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", 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)), - p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])), - 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, - ), - ] -} +mod audio; +mod common; +mod fill; +mod fractal; +mod paths; +mod raster; +mod registry; + +#[cfg(test)] +pub(crate) use audio::fft; +pub use audio::{MarksPlan, marks_plan}; +pub use fill::{eyedropper_color, paint_bucket_region}; +#[cfg(test)] +use paths::contours; +pub use paths::{Coverage, PaintPlan, paint_plan, plan_reads_pixels, scribble_style}; +pub use raster::{Seg, poly_segs}; +pub(crate) use raster::{mask_px, raster_segs}; +#[cfg(test)] +pub(crate) use raster::{raster_convex, stroke_outline}; +pub use registry::specs; #[cfg(test)] mod tests; diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs new file mode 100644 index 00000000..f1ac54db --- /dev/null +++ b/crates/effects/src/generate3/audio.rs @@ -0,0 +1,314 @@ +use super::raster::{Seg, mask_px, over, poly_segs, raster_segs}; +use super::*; + +// ---------------------------------------------------------------- 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)); + }); +} + +/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): +/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them. +#[derive(Clone, Debug)] +pub struct MarksPlan { + pub segs: Vec, + pub thickness: f64, + pub softness: f64, + pub inside: [f32; 4], + pub outside: [f32; 4], + pub composite: bool, +} + +/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio: +/// the layer passes through). +pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.generate.audiospectrum" => spectrum_plan(ctx, b), + "ec.generate.audiowaveform" => waveform_plan(ctx, b), + _ => None, + } +} + +fn marks(ctx: &EffectCtx, segs: Vec, thick: f64, soft: f64) -> MarksPlan { + let pr = ctx.params; + MarksPlan { + segs, + thickness: thick, + softness: soft * 2.0, + inside: pr.color("insideColor"), + outside: pr.color("outsideColor"), + composite: pr.b("compositeOnOriginal"), + } +} + +pub(super) fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(m) = spectrum_plan(ctx, &b) { + draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); + } + b +} + +pub(super) fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(m) = waveform_plan(ctx, &b) { + draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); + } + b +} + +fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec) { + let m = samples.len(); + let n = m.next_power_of_two().max(64); + let mut re = vec![0.0; n]; + let mut im = vec![0.0; n]; + let denom = (m.max(2) - 1) as f64; + for (i, s) in samples.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos(); + if let Some(r) = re.get_mut(i) { + *r = s * w; + } + } + fft(&mut re, &mut im); + let hz_per_bin = AUDIO_RATE as f64 / n as f64; + let scale = 4.0 / m.max(1) as f64; + let num_bins = n / 2 + 1; + let mut mags = Vec::with_capacity(num_bins); + for k in 0..num_bins { + let r = re.get(k).copied().unwrap_or(0.0); + let im_val = im.get(k).copied().unwrap_or(0.0); + let mag = (r * r + im_val * im_val).sqrt() * scale; + mags.push(mag); + } + (n, hz_per_bin, mags) +} + +fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?; + let (_n, hz_per_bin, mags) = compute_fft_mags(&samples); + + let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; + let f0 = pr.f("startFrequency").max(0.0); + let f1 = pr.f("endFrequency").max(f0 + 1.0); + let max_bin = mags.len().saturating_sub(1); + + let get_band_mag = |fc: f64| -> f64 { + if mags.is_empty() || hz_per_bin <= 0.0 { + return 0.0; + } + let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64); + let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1)); + let k1 = (k0 + 1).min(max_bin); + let frac = (k_float - k0 as f64).clamp(0.0, 1.0); + let m0 = mags.get(k0).copied().unwrap_or(0.0); + let m1 = mags.get(k1).copied().unwrap_or(0.0); + m0 * (1.0 - frac) + m1 * frac + }; + + 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 fc = f0 + (f1 - f0) * u; + let v = get_band_mag(fc); + let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 }; + 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 }); + } + } + } + Some(marks(ctx, segs, thick, soft)) +} + +fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?; + 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 })); + } + Some(marks(ctx, segs, thick, soft)) +} diff --git a/crates/effects/src/generate3/common.rs b/crates/effects/src/generate3/common.rs new file mode 100644 index 00000000..b996cdc9 --- /dev/null +++ b/crates/effects/src/generate3/common.rs @@ -0,0 +1,13 @@ +use super::*; + +pub(super) 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 } +} + +pub(super) fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +pub(super) fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} diff --git a/crates/effects/src/generate3/fill.rs b/crates/effects/src/generate3/fill.rs new file mode 100644 index 00000000..a86f123e --- /dev/null +++ b/crates/effects/src/generate3/fill.rs @@ -0,0 +1,334 @@ +use super::raster::over; +use super::*; + +pub(super) 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 + +/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the +/// layer's pixels (shared with the GPU compositor, which reads the layer back for it). +pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + 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)); + } + } + } + } + region +} + +pub(super) 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 region = paint_bucket_region(ctx, &b); + 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 + +pub(super) fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let fill = eyedropper_color(ctx, &b); + 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 +} + +/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with +/// the GPU compositor, which reads the layer back for it). +pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px { + 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; + } + } + } + } + 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] + } +} + +// ---------------------------------------------------------------- CC Threads + +pub(super) 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 +} diff --git a/crates/effects/src/generate3/fractal.rs b/crates/effects/src/generate3/fractal.rs new file mode 100644 index 00000000..519bec64 --- /dev/null +++ b/crates/effects/src/generate3/fractal.rs @@ -0,0 +1,193 @@ +use super::raster::over; +use super::*; + +// ---------------------------------------------------------------- Fractal + +#[derive(Clone, Copy)] +struct FracSet { + /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices). + over_julia: bool, + 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 +} + +/// Fractal Set Choice options. +pub(crate) const FRACTAL_SETS: [&str; 6] = + ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"]; + +/// Fractal Palette options. +pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"]; + +/// Fractal Oversample Method options. +pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"]; + +pub(super) 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("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY")); + let mmag = pr.f("mandelbrot/mandelbrotMagnification"); + let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01); + let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY")); + let jmag = pr.f("julia/juliaMagnification"); + let jlim = pr.f("julia/juliaEscapeLimit").max(1.01); + let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY")); + let overlay = pr.b("fractalColor/overlay"); + let transparency = pr.b("fractalColor/transparency"); + let transparent_inside = transparency || overlay; + let palette = pr.e("fractalColor/palette"); + let hue = (pr.f("fractalColor/hue") / 360.0) as f32; + let steps = pr.f("fractalColor/cycleSteps").max(1.0); + let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0; + let edge = pr.b("fractalColor/edgeHighlight"); + let every = pr.e("highQualitySettings/samplingMethod") == 1; + let factor = pr.f("highQualitySettings/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]); + // FRACTAL_SETS order. + let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse }; + let sets: (FracSet, Option) = match set_choice { + 1 => (set(false, true, false), None), + 2 => (set(false, false, true), None), + 3 => (set(false, true, true), None), + 4 => (set(true, false, false), None), + 5 => (set(true, true, false), None), + _ => (set(false, false, 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 { + let z0 = if s.over_julia { jc } else { (0.0, 0.0) }; + escape(z0, (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 { + // Solid Color: only the inside of the set is drawn (Transparency flips the sides). + if palette == 3 { + let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5); + return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] }; + } + let Some(n) = n else { + return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; + }; + let u = n / steps + cyc_off; + let band = (u.rem_euclid(1.0)) as f32; + let (r, g, bl) = match palette { + // Hue Wheel: the full hue circle at full saturation and brightness. + 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), + // Black And White: alternating bands. + 2 => { + let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 }; + (v, v, v) + } + // Lightness Gradient: black → hue → white, the hue moving 45° each cycle. + _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 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 +} diff --git a/crates/effects/src/generate3/paths.rs b/crates/effects/src/generate3/paths.rs new file mode 100644 index 00000000..779892cc --- /dev/null +++ b/crates/effects/src/generate3/paths.rs @@ -0,0 +1,553 @@ +use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly}; +use super::*; + +/// 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), + }; + }); +} + +/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs` +/// with `hardness`, or the union of convex polygons (`raster_convex`). +#[derive(Clone, Debug)] +pub enum Coverage { + Segs { segs: Vec, hardness: f64 }, + Convex(Vec>), +} + +impl Coverage { + fn raster(&self, w: usize, h: usize) -> Plane { + match self { + Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness), + Coverage::Convex(polys) => raster_convex(w, h, polys), + } + } +} + +/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu +/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original +/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). +#[derive(Clone, Debug)] +pub struct PaintPlan { + pub cov: Coverage, + pub color: [f32; 4], + pub opacity: f32, + pub style: u32, +} + +/// Draw a [`PaintPlan`]. +fn draw_paint(b: &mut Buf, plan: &PaintPlan) { + let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize); + if plan.style == 3 { + let color = plan.color; + 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); + }); + } else { + paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style); + } +} + +/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it. +pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool { + match id { + // Image Contours read the layer itself when no (resolvable) Input Layer is chosen. + "ec.generate.vegas" => ctx.params.e("stroke") == 0, + "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true, + "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0, + _ => false, + } +} + +/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read +/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is +/// cleared when it composites On Transparent, else kept). +pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.generate.stroke" => Some(stroke_plan(ctx, b)), + "ec.generate.scribble" => scribble_plan(ctx, b), + "ec.generate.vegas" => Some(vegas_plan(ctx, b)), + "ec.generate.writeon" => Some(write_on_plan(ctx, b)), + _ => None, + } +} + +/// Scribble's Composite as a `paint` style. +pub fn scribble_style(ctx: &EffectCtx) -> u32 { + match ctx.params.e("composite") { + 0 => 0, + 2 => 2, + _ => 1, + } +} + +pub(super) fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { + match scribble_plan(ctx, &b) { + Some(plan) => draw_paint(&mut b, &plan), + None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height), + None => {} + } + b +} + +fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option { + 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 = scribble_style(ctx); + if polys.is_empty() { + return None; + } + let sc = b.scale; + let fill = pr.e("fillType"); + let ew = pr.f("edgeOptions/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("strokeOptions/curviness") / 100.0; + let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0; + let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25); + let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0; + let overlap = pr.f("strokeOptions/pathOverlap") / 100.0; + let overlap_var = pr.f("strokeOptions/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; + // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. + let each = pr.e("startEndApplyTo") == 1; + let mut parts: Vec> = 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 = + if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; + acc += len; + parts.push(part); + } + } + let cap = pr.e("edgeOptions/endCap"); + let join = pr.e("edgeOptions/join"); + let cov = if cap == 0 && join == 0 { + let mut segs = Vec::new(); + for part in &parts { + poly_segs(part, false, r, 1.0, &mut segs); + } + Coverage::Segs { segs, hardness: 0.9 } + } else { + Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0))) + }; + Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }) +} + +// ---------------------------------------------------------------- Stroke + +pub(super) fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = stroke_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + 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 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; + } + } + } + PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style } +} + +// ---------------------------------------------------------------- Vegas + +/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). +pub(super) 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 +} + +pub(super) fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = vegas_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + 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; + // Shorter Contours Have Fewer Segments: segment count scales with contour length. + let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1; + let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max); + 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 nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg }; + 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 style = match pr.e("blendMode") { + 1 => 0, + 2 => 3, + 3 => 2, + _ => 1, + }; + PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style } +} + +// ---------------------------------------------------------------- Write-on / Glue Gun + +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. +pub(super) fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = write_on_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + +fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + let pr = ctx.params; + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } + PaintPlan { + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, + color: pr.color("color"), + opacity: if own_opacity { 1.0 } else { opacity(pr) }, + style: pr.e("paintStyle"), + } +} diff --git a/crates/effects/src/generate3/raster.rs b/crates/effects/src/generate3/raster.rs new file mode 100644 index 00000000..f9acd88a --- /dev/null +++ b/crates/effects/src/generate3/raster.rs @@ -0,0 +1,369 @@ +use super::*; + +// ---------------------------------------------------------------- shared rasterisation + +/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. +#[derive(Clone, Copy, Debug)] +pub 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 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 +} + +#[inline] +pub(super) 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. +pub(super) 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). +pub(super) 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 +} + +// ---------------------------------------------------------------- Scribble + +/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. +#[allow(clippy::too_many_arguments)] +pub(super) 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; +} + +/// End Cap and Join options (Scribble's Edge Options). +pub(super) const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; + +pub(super) const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; + +/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: +/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the +/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). +pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { + let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { + (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() + }; + let mut out = Vec::new(); + for l in lines { + let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { + if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { + v.push(p); + } + v + }); + if pts.len() < 2 { + if let (Some(p), 0) = (pts.first(), cap) { + out.push(disc(*p)); + } + continue; + } + let dir = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + [dx / l, dy / l] + }; + let n = pts.len(); + for k in 0..n - 1 { + let (mut a, mut b) = (pts[k], pts[k + 1]); + let d = dir(a, b); + // Projecting caps extend the end segments by the half width. + if cap == 2 && k == 0 { + a = [a[0] - d[0] * r, a[1] - d[1] * r]; + } + if cap == 2 && k == n - 2 { + b = [b[0] + d[0] * r, b[1] + d[1] * r]; + } + let nn = [-d[1] * r, d[0] * r]; + out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); + } + // Joins. + for k in 1..n - 1 { + let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); + let cross = d0[0] * d1[1] - d0[1] * d1[0]; + if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { + continue; + } + if join == 0 { + out.push(disc(p)); + continue; + } + // The outer side of the turn. + let s = if cross > 0.0 { -1.0 } else { 1.0 }; + let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; + let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; + let bevel = vec![p, o0, o1]; + if join == 1 { + // Miter point: where the two offset edges meet. + let bis = [d0[0] - d1[0], d0[1] - d1[1]]; + let bl = bis[0].hypot(bis[1]); + let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); + let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; + if bl > 1e-9 && len <= miter_limit * r { + let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; + let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); + let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; + out.push(vec![p, o0, tip, o1]); + continue; + } + } + out.push(bevel); + } + if cap == 0 { + out.push(disc(pts[0])); + out.push(disc(pts[n - 1])); + } + } + out +} + +/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. +pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { + let mut out = Plane::new(w, h); + let inside = |poly: &[[f64; 2]], x: f64, y: f64| { + let n = poly.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + if c.abs() < 1e-12 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true + }; + for poly in polys { + if poly.len() < 3 { + continue; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in poly { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); + let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); + for y in ya..yb { + for x in xa..xb { + let mut hit = 0; + for sy in 0..4 { + for sx in 0..4 { + if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { + hit += 1; + } + } + } + let v = &mut out.data[y * w + x]; + *v = v.max(hit as f32 / 16.0); + } + } + } + out +} diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs new file mode 100644 index 00000000..f7f6b040 --- /dev/null +++ b/crates/effects/src/generate3/registry.rs @@ -0,0 +1,265 @@ +use super::audio::{audio_spectrum, audio_waveform}; +use super::common::{pct, pt, spec}; +use super::fill::{eyedropper_fill, glue_gun, paint_bucket, threads}; +use super::fractal::{FRACTAL_OVERSAMPLE, FRACTAL_PALETTES, FRACTAL_SETS, fractal}; +use super::paths::{scribble, stroke, vegas, write_on}; +use super::raster::{END_CAPS, JOINS}; +use super::*; + +// ---------------------------------------------------------------- registry + +pub fn specs() -> Vec { + 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), ParamUi::Mask); + let display = || popup(&["Digital", "Analog lines", "Analog dots"]); + vec![ + spec( + "ec.generate.fractal", + "Fractal", + vec![ + p("setChoice", "Set Choice", Value::Enum(0), popup(&FRACTAL_SETS)), + p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), + p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + check("fractalColor/overlay", "Overlay", false), + check("fractalColor/transparency", "Transparency", false), + p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)), + p("fractalColor/hue", "Hue", num(0.0), ang()), + p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), + p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()), + check("fractalColor/edgeHighlight", "Edge Highlight", false), + p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)), + p("highQualitySettings/samplingFactor", "Oversample 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("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), + p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), + p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.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("strokeOptions/curviness", "Curviness", num(50.0), pct()), + p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()), + p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), + p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()), + p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), + 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", 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)), + p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])), + 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, + ), + ] +}