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