//! Material pixel tests: grab the lab's hidden window once every material's //! pipeline is ready and check each cube's column by what it must show. use makepad_test::{makepad_test, run_with_config, TestApp, TestConfig, TestError}; use makepad_zune_png::makepad_zune_core::bytestream::ZCursor; use makepad_zune_png::PngDecoder; struct Image { width: usize, height: usize, rgba: Vec, } impl Image { fn read(path: &std::path::Path) -> Image { let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read grab {}: {e}", path.display())); let mut decoder = PngDecoder::new(ZCursor::new(&bytes)); let pixels = decoder.decode_raw().unwrap_or_else(|e| panic!("cannot decode grab: {e:?}")); let (width, height) = decoder.dimensions().expect("grab has no dimensions"); let components = decoder.colorspace().expect("grab has no colorspace").num_components(); let mut rgba = vec![0u8; width * height * 4]; for i in 0..width * height { let src = i * components; rgba[i * 4..i * 4 + 3].copy_from_slice(&pixels[src..src + 3]); rgba[i * 4 + 3] = if components == 4 { pixels[src + 3] } else { 255 }; } Image { width, height, rgba } } fn px(&self, x: usize, y: usize) -> [i32; 3] { let p = (y * self.width + x) * 4; [self.rgba[p] as i32, self.rgba[p + 1] as i32, self.rgba[p + 2] as i32] } /// The first row below the window's title bar (the grab includes it). fn content_top(&self) -> usize { (0..self.height).find(|&y| { let p = self.px(4, y); (p[0] - 80).abs() > 12 || (p[2] - 80).abs() > 12 }).unwrap_or(0) } /// Pixels per metre at the row's distance, on screen. fn per_metre(&self) -> f32 { (self.height - self.content_top()) as f32 / (2.0 * 9.0 * 15f32.to_radians().tan()) } /// The centre of column `c`. The lab projects at the window's aspect, so /// a metre spans as many pixels across as down. fn column_x(&self, c: usize) -> f32 { self.width as f32 * 0.5 + (c as f32 - 3.0) * 1.4 * self.per_metre() } /// Pixels of column `c` in rows `y0..y1`. fn column_rows(&self, c: usize, y0: usize, y1: usize) -> Vec<(usize, [i32; 3])> { let (cx, half) = (self.column_x(c), 1.4 * self.per_metre() * 0.45); let (x0, x1) = ((cx - half).max(0.0) as usize, ((cx + half) as usize).min(self.width)); let mut out = Vec::new(); for y in y0..y1 { for x in x0..x1 { out.push((y, self.px(x, y))); } } out } /// Column `c`, both rows. fn column(&self, c: usize) -> Vec<(usize, [i32; 3])> { self.column_rows(c, self.content_top(), self.height) } /// The background row between the cube row and the sphere row above it, /// found down the middle of column 2 (Unlit in every scene, so it shows /// whatever the light). fn split(&self) -> usize { let x = self.column_x(2) as usize; let filled = |y: usize| !is_background(self.px(x, y)); let top = self.content_top(); let mut y = self.height - 1; while y > top && !filled(y) { y -= 1; } while y > top && filled(y) { y -= 1; } let gap = y; while y > top && !filled(y) { y -= 1; } (gap + y) / 2 } /// Column `c`'s cube (rows below `split`) and sphere (rows above). fn rows(&self, c: usize, split: usize) -> [Vec<(usize, [i32; 3])>; 2] { [self.column_rows(c, split, self.height), self.column_rows(c, self.content_top(), split)] } } /// The two rows of every scene, as `Image::rows` returns them. const ROWS: [&str; 2] = ["cube", "sphere"]; /// The pass clear colour (0.05, 0.06, 0.09): anything else is a cube. fn is_background(p: [i32; 3]) -> bool { (p[0] - 13).abs() <= 6 && (p[1] - 15).abs() <= 6 && (p[2] - 23).abs() <= 6 } fn fraction(col: &[(usize, [i32; 3])], pred: impl Fn([i32; 3]) -> bool) -> f32 { col.iter().filter(|(_, p)| pred(*p)).count() as f32 / col.len().max(1) as f32 } /// The mean row of a column's non-background pixels. fn centroid_y(col: &[(usize, [i32; 3])]) -> f32 { let hits: Vec = col.iter().filter(|(_, p)| !is_background(*p)).map(|(y, _)| *y).collect(); hits.iter().sum::() as f32 / hits.len().max(1) as f32 } #[makepad_test] fn each_material_hook_shows_in_its_column(app: TestApp) { app.wait_for_log_contains("material lab: ready"); std::thread::sleep(std::time::Duration::from_millis(800)); let path = app.screenshot(); println!("[material_lab] grab: {}", path.display()); let img = Image::read(&path); let split = img.split(); let red = |p: [i32; 3]| p[0] > 150 && p[1] < 60 && p[2] < 60; let green = |p: [i32; 3]| p[1] > 150 && p[0] < 60 && p[2] < 60; let magenta = |p: [i32; 3]| p[0] > 150 && p[2] > 150 && p[1] < 80; let blue = |p: [i32; 3]| p[2] > 150 && p[0] < 60 && p[1] < 60; let yellow = |p: [i32; 3]| p[0] > 140 && p[1] > 140 && p[2] < p[0] - 50; let grey = |p: [i32; 3]| !is_background(p) && (p[0] - p[1]).abs() < 30 && (p[1] - p[2]).abs() < 40; // Every hook on both shapes; the vertex column's lifted shapes straddle // the split, so only its coverage is checked per row. let all: Vec<_> = (0..7).map(|c| img.rows(c, split)).collect(); for (r, row) in ROWS.iter().enumerate() { let cols: Vec<_> = all.iter().map(|rows| &rows[r]).collect(); let covered = |c: usize| fraction(cols[c], |p| !is_background(p)); for c in 0..7 { println!("[material_lab] {row} column {c}: {:.3} covered, centroid row {:.1}", covered(c), centroid_y(cols[c])); assert!(covered(c) > 0.03, "{row} column {c} drew nothing"); } assert!(fraction(cols[0], grey) > 0.02, "stock lane: a lit grey {row}"); assert!(fraction(cols[0], red) + fraction(cols[0], green) + fraction(cols[0], blue) < 0.005, "stock lane {row} untouched by the hooks"); assert!(fraction(cols[1], red) > 0.8 * covered(1), "finish: red {row}"); assert!(fraction(cols[2], green) > 0.8 * covered(2), "unlit surface: flat green {row}"); assert!(fraction(cols[3], magenta) > 0.2 * covered(3), "error material: magenta hatching on the {row}"); assert!(fraction(cols[5], blue) > 0.8 * covered(5), "lighting: blue {row}"); assert!(fraction(cols[6], yellow) > 0.5 * covered(6), "light: yellow direct light on the {row}"); } // The vertex hook lifts its cube and sphere 0.9 m: their pixels sit well // above the stock pair's (rows count down the image). let cols: Vec<_> = (0..7).map(|c| img.column(c)).collect(); let per_metre = img.per_metre(); let lift = centroid_y(&cols[0]) - centroid_y(&cols[4]); assert!(lift > 0.6 * per_metre, "vertex: lifted {lift:.1} px, want about {:.1}", 0.9 * per_metre); } /// Grab the lab drawing a `World` scene (`--scene=`) once ready. fn world_scene(name: &str, test_name: &str) -> Image { let mut config = TestConfig::current_package(env!("CARGO_MANIFEST_DIR"), env!("CARGO_PKG_NAME"), test_name).unwrap(); config.app_args.push(format!("--scene={name}")); let mut out = None; run_with_config(config, |app: TestApp| -> Result<(), TestError> { app.wait_for_log_contains("material lab: ready, world scene"); std::thread::sleep(std::time::Duration::from_millis(800)); let path = app.screenshot(); println!("[material_lab] {name} grab: {}", path.display()); out = Some(Image::read(&path)); Ok(()) }) .unwrap(); out.unwrap() } fn luma(p: [i32; 3]) -> f32 { 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 } /// The mean luma of a column's non-background pixels. fn mean_luma(col: &[(usize, [i32; 3])]) -> f32 { let lit: Vec = col.iter().filter(|(_, p)| !is_background(*p)).map(|(_, p)| luma(*p)).collect(); lit.iter().sum::() / lit.len().max(1) as f32 } fn brightest(col: &[(usize, [i32; 3])]) -> f32 { col.iter().map(|(_, p)| luma(*p)).fold(0.0, f32::max) } #[test] fn world_items_and_a_rect_area_light_draw_in_the_dark() { let img = world_scene("rect", "pixels::world_items_and_a_rect_area_light_draw_in_the_dark"); let split = img.split(); let all: Vec<_> = (0..4).map(|c| img.rows(c, split)).collect(); let cyan = |p: [i32; 3]| p[1] > 150 && p[2] > 150 && p[0] < 60; let red = |p: [i32; 3]| p[0] > 150 && p[1] < 60 && p[2] < 60; for (r, row) in ROWS.iter().enumerate() { let cols: Vec<_> = all.iter().map(|rows| &rows[r]).collect(); for c in 0..4 { println!("[material_lab] rect {row} column {c}: brightest {:.1}, mean {:.1}", brightest(cols[c]), mean_luma(cols[c])); } // Means over the shape's pixels: a band's edges can catch a // neighbour's highlight. assert!(mean_luma(cols[0]) > 60.0, "the rect light lights its {row}"); assert!(mean_luma(cols[1]) < 15.0, "no light, no world sun: a dark {row}"); assert!(fraction(cols[2], cyan) > 0.02, "an Unlit {row} draws its colour in the dark"); assert!(fraction(cols[3], red) > 0.02, "a packed Instances item draws its tinted {row}s"); } } #[test] fn image_based_lighting_reflects_its_environment() { let img = world_scene("ibl", "pixels::image_based_lighting_reflects_its_environment"); let split = img.split(); let all: Vec<_> = (0..3).map(|c| img.rows(c, split)).collect(); for (r, row) in ROWS.iter().enumerate() { let cols: Vec<_> = all.iter().map(|rows| &rows[r]).collect(); for c in 0..3 { println!("[material_lab] ibl {row} column {c}: brightest {:.1}, mean {:.1}", brightest(cols[c]), mean_luma(cols[c])); } // Both metal shapes reflect the sunset (no other light is on), so // both are lit; the environment is warm at the horizon. for c in 0..2 { assert!(mean_luma(cols[c]) > 25.0, "column {c}: an IBL metal {row} reflects its environment"); } let warm = cols[0].iter().filter(|(_, p)| !is_background(*p)).filter(|(_, p)| p[0] > p[2] + 10).count(); assert!(warm > 50, "the sunset's warm horizon shows in the {row}'s reflection"); } }