From 0e9ae5a49fa994447880c7788e9c48179986d8ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:46 +0000 Subject: [PATCH] refactor(effects): move src/channel3d.rs tests to src/channel3d/tests.rs --- crates/effects/src/channel3d.rs | 382 +------------------------- crates/effects/src/channel3d/tests.rs | 379 +++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 381 deletions(-) create mode 100644 crates/effects/src/channel3d/tests.rs diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index ea194f6da..08241107c 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -686,384 +686,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; - use effectcraft_raster::channels3d::crypto_float; - - /// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the - /// top-right corner is background. Cryptomatte: "left" / "right". - struct AuxHost(Arc); - impl EffectHost for AuxHost { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn aux(&self) -> Option> { - Some(self.0.clone()) - } - } - - fn scene() -> AuxHost { - let (w, h) = (20usize, 10usize); - let mut a = AuxChannels::new(w as u32, h as u32, 1.0); - let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]); - let (hl, hr) = (crypto_hash("left"), crypto_hash("right")); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - if x >= 15 && y < 3 { - z[i] = BACKGROUND_DEPTH; - } else if x < 10 { - z[i] = 100.0; - id[i] = 1.0; - r[i] = crypto_float(hl); - g[i] = 1.0; - } else { - z[i] = 500.0; - id[i] = 2.0; - r[i] = crypto_float(hr); - g[i] = 1.0; - } - } - } - a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)]; - a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])]; - AuxHost(Arc::new(a)) - } - - fn img() -> Image { - Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]) - } - - fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image { - run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img - } - - #[test] - fn passes_through_without_aux_channels() { - for s in specs() { - let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default()); - assert_eq!(out.img.data, img().data, "{}", s.id); - } - } - - #[test] - fn depth_matte_thresholds() { - let h = scene(); - let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h); - assert_eq!(o.get(2, 5)[3], 0.0); - assert_eq!(o.get(12, 5)[3], 1.0); - let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h); - assert_eq!(inv.get(2, 5)[3], 1.0); - assert_eq!(inv.get(12, 5)[3], 0.0); - // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75. - let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h); - assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5); - } - - #[test] - fn channel_extract_maps_depth_and_ids() { - let h = scene(); - let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h); - assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6); - assert_eq!(o.get(17, 1)[0], 1.0); - let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h); - assert_eq!(ids.get(2, 5)[0], 0.5); - // Invert Depth Map flips the ramp; without Clamp Output values run past 1. - let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h); - assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6); - let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h); - assert!(raw.get(12, 5)[0] > 1.5); - } - - #[test] - fn id_matte_and_identifier() { - let h = scene(); - let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h); - assert_eq!(o.get(2, 5)[3], 0.0); - assert_eq!(o.get(12, 5)[3], 1.0); - let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h); - assert_eq!(l.get(2, 5)[0], 1.0); - assert_eq!(l.get(12, 5)[0], 0.0); - let c = run("ec.3d.identifier", &[], &h); - assert_ne!(c.get(2, 5), c.get(12, 5)); - } - - /// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel - /// reduction over `crypto_ranks` it replaces. - #[test] - fn crypto_aux_image_matches_the_per_pixel_reduction() { - let (w, h) = (7u32, 5u32); - let n = (w * h) as usize; - let mut a = AuxChannels::new(w, h, 1.0); - let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float); - for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() { - let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect(); - a.channels.push((format!("CryptoObject{ch}"), v)); - } - let sel = vec![crypto_hash("sphere"), crypto_hash("ring")]; - let img = crypto_aux_image(&a, "CryptoObject", &sel); - for i in 0..n { - let ranks = a.crypto_ranks("CryptoObject", i); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } - let want = [c[0], c[1], c[2], m]; - assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); - } - } - - /// The lookup before #473, kept as the reference: the rank channels formatted and searched - /// by name for every pixel. - fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> { - let mut out = vec![]; - for k in 0.. { - let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}")); - let (Some(r), Some(g)) = (get("R"), get("G")) else { break }; - out.push((r[i], g[i])); - if let (Some(b), Some(al)) = (get("B"), get("A")) { - out.push((b[i], al[i])); - } - } - out - } - - /// The reduction before #473 over those ranks: (ID colours, selection coverage). - fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) { - let ranks = ranks_by_name(a, layer, i); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } - (c, m) - } - - /// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN - /// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values - /// that would change every pixel) must be ignored as before. - fn edge_aux(with_rank_after_gap: bool) -> AuxChannels { - let (w, h) = (6u32, 2u32); - let nan_payload = f32::from_bits(0x7fc0_1234); - let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube"))); - let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5]; - let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0]; - let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() }; - let mut a = AuxChannels::new(w, h, 1.0); - let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v)); - push("00.R", rot(&ids, 0)); - push("00.G", rot(&cov, 0)); - push("00.B", rot(&ids, 3)); - push("00.A", rot(&cov, 5)); - push("01.R", rot(&ids, 7)); - push("01.G", rot(&cov, 2)); - if with_rank_after_gap { - push("03.R", vec![hs; 12]); - push("03.G", vec![1.0; 12]); - push("03.B", vec![hc; 12]); - push("03.A", vec![1.0; 12]); - } - a - } - - /// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values - /// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits). - #[test] - fn id_color_memo_matches_id_color() { - let a = edge_aux(true); - let planes = a.crypto_rank_planes("CryptoObject"); - let mut memo = IdColorMemo::default(); - for i in 0..12 { - for (r, (id, _)) in planes.iter().enumerate() { - assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}"); - } - } - } - - fn edge_selection() -> Vec { - vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] - } - - /// Edge values give bit-identical results to the lookup by name, the missing B/A pair is - /// skipped and a rank after a gap is ignored. - #[test] - fn crypto_aux_image_is_bit_exact_on_edge_values() { - let sel = edge_selection(); - let a = edge_aux(true); - assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap"); - let img = crypto_aux_image(&a, "CryptoObject", &sel); - let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel); - for i in 0..12 { - let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i); - let want = [c[0], c[1], c[2], m]; - assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); - assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used"); - } - } - - /// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values. - #[test] - fn crypto_aux_image_without_colors_keeps_the_matte() { - let a = edge_aux(true); - let sel = edge_selection(); - let full = crypto_aux_image(&a, "CryptoObject", &sel); - let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly); - for (f, m) in full.data.iter().zip(&matte.data) { - assert_eq!(f[3].to_bits(), m[3].to_bits()); - assert_eq!(m[..3], [0.0; 3]); - } - } - - /// Only the Colors display without Matte Only reads the ID colours. - #[test] - fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() { - assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte); - assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly); - for display in [1, 2, 3, u32::MAX] { - for matte_only in [false, true] { - assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}"); - } - } - } - - /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match - /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) - /// get an empty matte and no ID colour. - #[test] - fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() { - let a = edge_aux(true); - let host = AuxHost(Arc::new(a.clone())); - let names = "sphere, <7fc00000>, <7fc01234>, <80000000>"; - assert_eq!(selection_hashes(names, None), edge_selection()); - let src = img().data[0]; - for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] { - let out = run( - "ec.3d.cryptomatte", - &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))], - &host, - ); - let expect = |c: [f32; 3], m: f32| -> [f32; 4] { - if matte_only { - return [m; 4]; - } - match display { - 0 => { - let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); - [c[0], c[1], c[2], 1.0] - } - 1 => src.map(|v| v * m), - _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]], - } - }; - for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] { - let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize); - assert_eq!( - out.get(x as i64, y as i64).map(f32::to_bits), - expect(c, m).map(f32::to_bits), - "display {display} matteOnly {matte_only} at ({x}, {y})" - ); - } - for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] { - assert_eq!( - out.get(x as i64, y as i64).map(f32::to_bits), - expect([0.0; 3], 0.0).map(f32::to_bits), - "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})" - ); - } - } - } - - #[test] - fn cryptomatte_selects_by_name_and_wildcard() { - let h = scene(); - let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h); - assert_eq!(m.get(2, 5)[3], 0.0); - assert_eq!(m.get(12, 5)[3], 1.0); - let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h); - assert_eq!(w.get(2, 5)[3], 1.0); - assert_eq!(w.get(12, 5)[3], 0.0); - assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right")); - let hex = format!("<{:08x}>", crypto_hash("left")); - assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]); - } - - /// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is - /// quoted or its comma escaped, instead of being cut into two names that match nothing. - #[test] - fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() { - let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)]; - let m = Some(manifest.as_slice()); - assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]); - assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]); - assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]); - assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]); - // Without a manifest the whole name is hashed. - assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]); - // Plain lists, apostrophes inside names and an unclosed quote still parse. - assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]); - assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]); - assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]); - assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]); - } - - #[test] - fn fog_and_depth_of_field_follow_depth() { - let h = scene(); - let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h); - // Linear fog: 10 % at depth 100, 50 % at 500, full on the background. - assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5)); - assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4); - assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4); - assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6); - assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6); - assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0); - // A sharp edge at the far object blurs when focused near. - let mut pic = Image::new(20, 10); - for y in 0..10 { - for x in 0..20 { - let v = if x % 2 == 0 { 1.0 } else { 0.0 }; - pic.set(x, y, [v, v, v, 1.0]); - } - } - let env = EffectEnv { host: Some(&h), ..Default::default() }; - let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img; - assert_eq!(d.get(4, 5), pic.get(4, 5)); - assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5)); - } - - #[test] - fn extractor_maps_named_channels() { - let h = scene(); - let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h); - assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6); - assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6); - // Without aux: the layer's own channels can be swapped. - let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); - assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); - } - - #[test] - fn channel_extract_antialias_averages_sub_pixels() { - // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. - let mut a = AuxChannels::new(40, 20, 2.0); - a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; - let host = AuxHost(Arc::new(a)); - let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; - let hard = run("ec.3d.channelextract", &vals, &host); - let v = hard.get(5, 5)[0]; - assert!(v == 0.0 || v == 1.0, "{v}"); - let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); - assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); - } -} +mod tests; diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs new file mode 100644 index 000000000..0c665d62d --- /dev/null +++ b/crates/effects/src/channel3d/tests.rs @@ -0,0 +1,379 @@ +use super::*; +use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; +use effectcraft_raster::channels3d::crypto_float; + +/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the +/// top-right corner is background. Cryptomatte: "left" / "right". +struct AuxHost(Arc); +impl EffectHost for AuxHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn aux(&self) -> Option> { + Some(self.0.clone()) + } +} + +fn scene() -> AuxHost { + let (w, h) = (20usize, 10usize); + let mut a = AuxChannels::new(w as u32, h as u32, 1.0); + let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]); + let (hl, hr) = (crypto_hash("left"), crypto_hash("right")); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if x >= 15 && y < 3 { + z[i] = BACKGROUND_DEPTH; + } else if x < 10 { + z[i] = 100.0; + id[i] = 1.0; + r[i] = crypto_float(hl); + g[i] = 1.0; + } else { + z[i] = 500.0; + id[i] = 2.0; + r[i] = crypto_float(hr); + g[i] = 1.0; + } + } + } + a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)]; + a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])]; + AuxHost(Arc::new(a)) +} + +fn img() -> Image { + Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]) +} + +fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image { + run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img +} + +#[test] +fn passes_through_without_aux_channels() { + for s in specs() { + let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, img().data, "{}", s.id); + } +} + +#[test] +fn depth_matte_thresholds() { + let h = scene(); + let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h); + assert_eq!(o.get(2, 5)[3], 0.0); + assert_eq!(o.get(12, 5)[3], 1.0); + let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h); + assert_eq!(inv.get(2, 5)[3], 1.0); + assert_eq!(inv.get(12, 5)[3], 0.0); + // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75. + let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h); + assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5); +} + +#[test] +fn channel_extract_maps_depth_and_ids() { + let h = scene(); + let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h); + assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6); + assert_eq!(o.get(17, 1)[0], 1.0); + let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h); + assert_eq!(ids.get(2, 5)[0], 0.5); + // Invert Depth Map flips the ramp; without Clamp Output values run past 1. + let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h); + assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6); + let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h); + assert!(raw.get(12, 5)[0] > 1.5); +} + +#[test] +fn id_matte_and_identifier() { + let h = scene(); + let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h); + assert_eq!(o.get(2, 5)[3], 0.0); + assert_eq!(o.get(12, 5)[3], 1.0); + let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h); + assert_eq!(l.get(2, 5)[0], 1.0); + assert_eq!(l.get(12, 5)[0], 0.0); + let c = run("ec.3d.identifier", &[], &h); + assert_ne!(c.get(2, 5), c.get(12, 5)); +} + +/// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel +/// reduction over `crypto_ranks` it replaces. +#[test] +fn crypto_aux_image_matches_the_per_pixel_reduction() { + let (w, h) = (7u32, 5u32); + let n = (w * h) as usize; + let mut a = AuxChannels::new(w, h, 1.0); + let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float); + for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() { + let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect(); + a.channels.push((format!("CryptoObject{ch}"), v)); + } + let sel = vec![crypto_hash("sphere"), crypto_hash("ring")]; + let img = crypto_aux_image(&a, "CryptoObject", &sel); + for i in 0..n { + let ranks = a.crypto_ranks("CryptoObject", i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + } +} + +/// The lookup before #473, kept as the reference: the rank channels formatted and searched +/// by name for every pixel. +fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> { + let mut out = vec![]; + for k in 0.. { + let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}")); + let (Some(r), Some(g)) = (get("R"), get("G")) else { break }; + out.push((r[i], g[i])); + if let (Some(b), Some(al)) = (get("B"), get("A")) { + out.push((b[i], al[i])); + } + } + out +} + +/// The reduction before #473 over those ranks: (ID colours, selection coverage). +fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) { + let ranks = ranks_by_name(a, layer, i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + (c, m) +} + +/// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN +/// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values +/// that would change every pixel) must be ignored as before. +fn edge_aux(with_rank_after_gap: bool) -> AuxChannels { + let (w, h) = (6u32, 2u32); + let nan_payload = f32::from_bits(0x7fc0_1234); + let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube"))); + let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5]; + let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0]; + let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() }; + let mut a = AuxChannels::new(w, h, 1.0); + let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v)); + push("00.R", rot(&ids, 0)); + push("00.G", rot(&cov, 0)); + push("00.B", rot(&ids, 3)); + push("00.A", rot(&cov, 5)); + push("01.R", rot(&ids, 7)); + push("01.G", rot(&cov, 2)); + if with_rank_after_gap { + push("03.R", vec![hs; 12]); + push("03.G", vec![1.0; 12]); + push("03.B", vec![hc; 12]); + push("03.A", vec![1.0; 12]); + } + a +} + +/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values +/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits). +#[test] +fn id_color_memo_matches_id_color() { + let a = edge_aux(true); + let planes = a.crypto_rank_planes("CryptoObject"); + let mut memo = IdColorMemo::default(); + for i in 0..12 { + for (r, (id, _)) in planes.iter().enumerate() { + assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}"); + } + } +} + +fn edge_selection() -> Vec { + vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] +} + +/// Edge values give bit-identical results to the lookup by name, the missing B/A pair is +/// skipped and a rank after a gap is ignored. +#[test] +fn crypto_aux_image_is_bit_exact_on_edge_values() { + let sel = edge_selection(); + let a = edge_aux(true); + assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap"); + let img = crypto_aux_image(&a, "CryptoObject", &sel); + let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel); + for i in 0..12 { + let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i); + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used"); + } +} + +/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values. +#[test] +fn crypto_aux_image_without_colors_keeps_the_matte() { + let a = edge_aux(true); + let sel = edge_selection(); + let full = crypto_aux_image(&a, "CryptoObject", &sel); + let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly); + for (f, m) in full.data.iter().zip(&matte.data) { + assert_eq!(f[3].to_bits(), m[3].to_bits()); + assert_eq!(m[..3], [0.0; 3]); + } +} + +/// Only the Colors display without Matte Only reads the ID colours. +#[test] +fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() { + assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte); + assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly); + for display in [1, 2, 3, u32::MAX] { + for matte_only in [false, true] { + assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}"); + } + } +} + +/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match +/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) +/// get an empty matte and no ID colour. +#[test] +fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() { + let a = edge_aux(true); + let host = AuxHost(Arc::new(a.clone())); + let names = "sphere, <7fc00000>, <7fc01234>, <80000000>"; + assert_eq!(selection_hashes(names, None), edge_selection()); + let src = img().data[0]; + for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] { + let out = run( + "ec.3d.cryptomatte", + &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))], + &host, + ); + let expect = |c: [f32; 3], m: f32| -> [f32; 4] { + if matte_only { + return [m; 4]; + } + match display { + 0 => { + let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); + [c[0], c[1], c[2], 1.0] + } + 1 => src.map(|v| v * m), + _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]], + } + }; + for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] { + let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize); + assert_eq!( + out.get(x as i64, y as i64).map(f32::to_bits), + expect(c, m).map(f32::to_bits), + "display {display} matteOnly {matte_only} at ({x}, {y})" + ); + } + for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] { + assert_eq!( + out.get(x as i64, y as i64).map(f32::to_bits), + expect([0.0; 3], 0.0).map(f32::to_bits), + "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})" + ); + } + } +} + +#[test] +fn cryptomatte_selects_by_name_and_wildcard() { + let h = scene(); + let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h); + assert_eq!(m.get(2, 5)[3], 0.0); + assert_eq!(m.get(12, 5)[3], 1.0); + let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h); + assert_eq!(w.get(2, 5)[3], 1.0); + assert_eq!(w.get(12, 5)[3], 0.0); + assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right")); + let hex = format!("<{:08x}>", crypto_hash("left")); + assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]); +} + +/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is +/// quoted or its comma escaped, instead of being cut into two names that match nothing. +#[test] +fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() { + let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)]; + let m = Some(manifest.as_slice()); + assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]); + assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]); + assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]); + assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]); + // Without a manifest the whole name is hashed. + assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]); + // Plain lists, apostrophes inside names and an unclosed quote still parse. + assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]); + assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]); + assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]); + assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]); +} + +#[test] +fn fog_and_depth_of_field_follow_depth() { + let h = scene(); + let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h); + // Linear fog: 10 % at depth 100, 50 % at 500, full on the background. + assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5)); + assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4); + assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4); + assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6); + assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6); + assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0); + // A sharp edge at the far object blurs when focused near. + let mut pic = Image::new(20, 10); + for y in 0..10 { + for x in 0..20 { + let v = if x % 2 == 0 { 1.0 } else { 0.0 }; + pic.set(x, y, [v, v, v, 1.0]); + } + } + let env = EffectEnv { host: Some(&h), ..Default::default() }; + let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img; + assert_eq!(d.get(4, 5), pic.get(4, 5)); + assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5)); +} + +#[test] +fn extractor_maps_named_channels() { + let h = scene(); + let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h); + assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6); + assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6); + // Without aux: the layer's own channels can be swapped. + let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); + assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); +} + +#[test] +fn channel_extract_antialias_averages_sub_pixels() { + // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. + let mut a = AuxChannels::new(40, 20, 2.0); + a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; + let host = AuxHost(Arc::new(a)); + let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; + let hard = run("ec.3d.channelextract", &vals, &host); + let v = hard.get(5, 5)[0]; + assert!(v == 0.0 || v == 1.0, "{v}"); + let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); + assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); +}