diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index fcb05d6e2..62bce1706 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -1223,315 +1223,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::Params; - use effectcraft_raster::Image; - - fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { - let s = crate::find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - let ls = [img.width as f64, img.height as f64]; - 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 { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) - } - - #[test] - fn cell_pattern_is_deterministic_and_seeded() { - let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - assert_eq!(a.img, b2.img); - let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); - assert_ne!(a.img, c.img); - assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); - } - - #[test] - fn ellipse_ring_coverage() { - let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); - // Ring passes through (32 + 20, 32); centre is empty. - assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); - assert!(o.img.get(32, 32)[3] < 1e-3); - assert!(o.img.get(2, 2)[3] < 1e-3); - } - - #[test] - fn lightning_is_deterministic_per_conductivity() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - assert_eq!(a.img, b2.img); - assert!(a.img.data.iter().any(|p| p[3] > 0.5)); - let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); - assert_ne!(a.img, c.img); - } - - #[test] - fn beam_draws_between_points() { - let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); - assert!(o.img.get(32, 16)[3] > 0.99); - assert!(o.img.get(32, 2)[3] < 1e-3); - assert!(o.img.get(2, 16)[3] < 1e-3); - } - - #[test] - fn cell_pattern_tiles() { - // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. - let set = [ - ("size", num(8.0)), - ("tilingOptions/enableTiling", Value::Bool(true)), - ("tilingOptions/cellsHorizontal", num(4.0)), - ("tilingOptions/cellsVertical", num(4.0)), - ]; - let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); - for (x, y) in [(3, 5), (10, 20), (17, 2)] { - let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); - assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); - } - } - - #[test] - fn radio_waves_expansion_velocity_and_star() { - let r = |set: &[(&str, Value)], t: f64| { - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - for (k, v) in set { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) - }; - // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. - let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; - let o = r(&base, 0.2); - assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); - assert!(o.img.get(32, 32)[3] < 0.01); - // Velocity moves the wave toward Direction (90° = right). - let mut moving = base.to_vec(); - moving.push(("waveMotion/velocity", num(50.0))); - let o = r(&moving, 0.2); - assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); - // A 4-point star: the outline dips toward the centre between the points. - let mut star = base.to_vec(); - star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); - let o = r(&star, 0.2); - assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); - // Between points (45°) the outline sits near half the radius. - assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); - assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); - } - - #[test] - fn lightning_alpha_obstacle_and_expert_settings() { - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); - // A wall of opaque pixels across the middle of the bolt's path. - let mut wall = Image::new(64, 48); - for y in 0..48 { - for x in 0..64 { - if (18..30).contains(&y) && x < 50 { - wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); - } - } - } - let opts = |o: f64| { - vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] - }; - let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); - let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); - assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); - // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. - let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - for (id, v) in [ - ("expertSettings/fractalType", Value::Enum(2)), - ("expertSettings/coreDrain", num(100.0)), - ("expertSettings/forkStrength", num(100.0)), - ("expertSettings/forkVariation", num(100.0)), - ] { - assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); - } - let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); - assert!(sum(&drained.img) < sum(&base.img)); - let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); - let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); - assert_eq!(no_forks.img, term.img, "no forks either way"); - assert!(sum(&no_forks.img) < sum(&base.img)); - } - - #[test] - fn lightning_types_and_decay_main_core() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); - assert_eq!(a.img, strike.img, "Strike is the default"); - for t in 0..8 { - let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); - assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); - } - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); - let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); - assert!(sum(&d.img) < sum(&n.img)); - } - - #[test] - fn beam_3d_perspective_controls_thickness() { - // Short beam near the start: with 3D Perspective its thickness comes from its position - // (thin, near the start); without, its head takes the full ending thickness. - let set = |p: bool| { - vec![ - ("length", num(20.0)), - ("time", num(0.0)), - ("softness", num(0.0)), - ("startThickness", num(2.0)), - ("endThickness", num(20.0)), - ("perspective3d", Value::Bool(p)), - ] - }; - let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); - let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); - } - - #[test] - fn lens_flare_brightest_at_center() { - let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); - let c = o.img.get(16, 16)[0]; - assert!(c > o.img.get(60, 4)[0]); - assert!(c > 0.5); - } - - fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { - let mut v: Vec<(&str, Value)> = - vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; - v.extend_from_slice(set); - crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img - } - - #[test] - fn signed_distance_of_a_square() { - let (w, h) = (20, 20); - let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); - let d = crate::util::signed_distance(&inside, w, h); - assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); - assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); - assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); - } - - #[test] - fn radio_waves_mask_contours_profile_reflection_and_birth() { - // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. - let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; - let masks = [sq]; - let env = crate::EffectEnv { masks: &masks, ..Default::default() }; - let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); - assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); - assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); - // Rounded corner: diagonal distance 10 from (37, 37). - assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); - // No mask chosen: nothing drawn. - assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); - // Image Contours: the bright square of a source layer. - struct Src; - impl crate::EffectHost for Src { - fn layer(&self, _: u64, _: bool) -> Option { - let mut img = Image::new(64, 64); - for y in 27..37 { - for x in 27..37 { - img.set(x, y, [1.0, 1.0, 1.0, 1.0]); - } - } - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - // Colour animates from red (born at 0) to green (born later). - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - p.values.insert("waveMotion/frequency".into(), num(1.0)); - p.values.insert("waveMotion/expansion".into(), num(20.0)); - p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); - Some(p) - } - } - let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; - let ic = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); - // Source Center off the square shifts the contour onto the producer point accordingly. - let sh = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); - // Profile: Taper Both thins the stroke edges; Square is solid across. - let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; - let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); - let mut tap = wide.to_vec(); - tap.push(("waveStroke/profile", Value::Enum(3))); - let tap = rw(&tap, 0.2, crate::EffectEnv::default()); - assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); - assert!(tap.get(52, 32)[3] > 0.8); - // Reflection: a fast wave bounces back off the right edge. - let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; - let gone = rw(&fast, 0.3, crate::EffectEnv::default()); - let mut rf = fast.to_vec(); - rf.push(("waveMotion/reflection", Value::Bool(true))); - let back = rw(&rf, 0.3, crate::EffectEnv::default()); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); - // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. - let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); - let (old, new) = (both.get(55, 32), both.get(35, 32)); - assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); - assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); - } - - #[test] - fn cell_pattern_hq_variants_antialias() { - let s = crate::find("ec.generate.cellpattern").unwrap(); - let r = |pat: u32| { - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("cellPattern".into(), Value::Enum(pat)); - params.values.insert("size".into(), num(12.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img - }; - let distinct = |img: &Image| { - let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); - v.sort_unstable(); - v.dedup(); - v.len() - }; - let (std, hq) = (r(4), r(9)); - assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); - assert_ne!(std, hq); - assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); - } -} +mod tests; diff --git a/crates/effects/src/generate2/tests.rs b/crates/effects/src/generate2/tests.rs new file mode 100644 index 000000000..6e4783b48 --- /dev/null +++ b/crates/effects/src/generate2/tests.rs @@ -0,0 +1,310 @@ +use super::*; +use crate::Params; +use effectcraft_raster::Image; + +fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let ls = [img.width as f64, img.height as f64]; + 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 { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + +#[test] +fn cell_pattern_is_deterministic_and_seeded() { + let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + assert_eq!(a.img, b2.img); + let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); + assert_ne!(a.img, c.img); + assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); +} + +#[test] +fn ellipse_ring_coverage() { + let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); + // Ring passes through (32 + 20, 32); centre is empty. + assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); + assert!(o.img.get(32, 32)[3] < 1e-3); + assert!(o.img.get(2, 2)[3] < 1e-3); +} + +#[test] +fn lightning_is_deterministic_per_conductivity() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + assert_eq!(a.img, b2.img); + assert!(a.img.data.iter().any(|p| p[3] > 0.5)); + let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); + assert_ne!(a.img, c.img); +} + +#[test] +fn beam_draws_between_points() { + let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); + assert!(o.img.get(32, 16)[3] > 0.99); + assert!(o.img.get(32, 2)[3] < 1e-3); + assert!(o.img.get(2, 16)[3] < 1e-3); +} + +#[test] +fn cell_pattern_tiles() { + // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. + let set = [ + ("size", num(8.0)), + ("tilingOptions/enableTiling", Value::Bool(true)), + ("tilingOptions/cellsHorizontal", num(4.0)), + ("tilingOptions/cellsVertical", num(4.0)), + ]; + let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); + for (x, y) in [(3, 5), (10, 20), (17, 2)] { + let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); + assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); + } +} + +#[test] +fn radio_waves_expansion_velocity_and_star() { + let r = |set: &[(&str, Value)], t: f64| { + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) + }; + // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. + let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; + let o = r(&base, 0.2); + assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); + assert!(o.img.get(32, 32)[3] < 0.01); + // Velocity moves the wave toward Direction (90° = right). + let mut moving = base.to_vec(); + moving.push(("waveMotion/velocity", num(50.0))); + let o = r(&moving, 0.2); + assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); + // A 4-point star: the outline dips toward the centre between the points. + let mut star = base.to_vec(); + star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); + let o = r(&star, 0.2); + assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); + // Between points (45°) the outline sits near half the radius. + assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); + assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); +} + +#[test] +fn lightning_alpha_obstacle_and_expert_settings() { + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); + // A wall of opaque pixels across the middle of the bolt's path. + let mut wall = Image::new(64, 48); + for y in 0..48 { + for x in 0..64 { + if (18..30).contains(&y) && x < 50 { + wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + } + let opts = |o: f64| { + vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] + }; + let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); + let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); + assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); + // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. + let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + for (id, v) in [ + ("expertSettings/fractalType", Value::Enum(2)), + ("expertSettings/coreDrain", num(100.0)), + ("expertSettings/forkStrength", num(100.0)), + ("expertSettings/forkVariation", num(100.0)), + ] { + assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); + } + let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); + assert!(sum(&drained.img) < sum(&base.img)); + let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); + let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); + assert_eq!(no_forks.img, term.img, "no forks either way"); + assert!(sum(&no_forks.img) < sum(&base.img)); +} + +#[test] +fn lightning_types_and_decay_main_core() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); + assert_eq!(a.img, strike.img, "Strike is the default"); + for t in 0..8 { + let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); + assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); + } + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); + let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); + assert!(sum(&d.img) < sum(&n.img)); +} + +#[test] +fn beam_3d_perspective_controls_thickness() { + // Short beam near the start: with 3D Perspective its thickness comes from its position + // (thin, near the start); without, its head takes the full ending thickness. + let set = |p: bool| { + vec![ + ("length", num(20.0)), + ("time", num(0.0)), + ("softness", num(0.0)), + ("startThickness", num(2.0)), + ("endThickness", num(20.0)), + ("perspective3d", Value::Bool(p)), + ] + }; + let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); + let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); +} + +#[test] +fn lens_flare_brightest_at_center() { + let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); + let c = o.img.get(16, 16)[0]; + assert!(c > o.img.get(60, 4)[0]); + assert!(c > 0.5); +} + +fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { + let mut v: Vec<(&str, Value)> = + vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; + v.extend_from_slice(set); + crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img +} + +#[test] +fn signed_distance_of_a_square() { + let (w, h) = (20, 20); + let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); + let d = crate::util::signed_distance(&inside, w, h); + assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); + assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); + assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); +} + +#[test] +fn radio_waves_mask_contours_profile_reflection_and_birth() { + // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. + let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; + let masks = [sq]; + let env = crate::EffectEnv { masks: &masks, ..Default::default() }; + let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); + assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); + assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); + // Rounded corner: diagonal distance 10 from (37, 37). + assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); + // No mask chosen: nothing drawn. + assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); + // Image Contours: the bright square of a source layer. + struct Src; + impl crate::EffectHost for Src { + fn layer(&self, _: u64, _: bool) -> Option { + let mut img = Image::new(64, 64); + for y in 27..37 { + for x in 27..37 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + // Colour animates from red (born at 0) to green (born later). + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + p.values.insert("waveMotion/frequency".into(), num(1.0)); + p.values.insert("waveMotion/expansion".into(), num(20.0)); + p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); + Some(p) + } + } + let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; + let ic = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); + // Source Center off the square shifts the contour onto the producer point accordingly. + let sh = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); + // Profile: Taper Both thins the stroke edges; Square is solid across. + let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; + let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); + let mut tap = wide.to_vec(); + tap.push(("waveStroke/profile", Value::Enum(3))); + let tap = rw(&tap, 0.2, crate::EffectEnv::default()); + assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); + assert!(tap.get(52, 32)[3] > 0.8); + // Reflection: a fast wave bounces back off the right edge. + let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; + let gone = rw(&fast, 0.3, crate::EffectEnv::default()); + let mut rf = fast.to_vec(); + rf.push(("waveMotion/reflection", Value::Bool(true))); + let back = rw(&rf, 0.3, crate::EffectEnv::default()); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); + // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. + let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); + let (old, new) = (both.get(55, 32), both.get(35, 32)); + assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); + assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); +} + +#[test] +fn cell_pattern_hq_variants_antialias() { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let r = |pat: u32| { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("cellPattern".into(), Value::Enum(pat)); + params.values.insert("size".into(), num(12.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img + }; + let distinct = |img: &Image| { + let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); + v.sort_unstable(); + v.dedup(); + v.len() + }; + let (std, hq) = (r(4), r(9)); + assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); + assert_ne!(std, hq); + assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); +}