//! Headless pictures drawn with the viewport's pipelines: what they show (appearances, guides, //! silhouettes, the section and its cap, surface analysis) and that bad requests are errors. //! GPU output differs a little between drivers, so the checks count pixels of a kind rather than //! compare bytes. Skipped, with the reason printed, on a machine without a GPU adapter //! (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure). use serde_json::json; use solidcraft_engine::Session; use solidcraft_engine::geom::Vec3; use solidcraft_engine::render::{Camera, Rgb, SceneLine}; use solidcraft_engine::view::{self, Guides, colors}; use solidcraft_gpu::GpuError; use solidcraft_gpu::capture::{self, Capturer}; fn capturer() -> Option<&'static Capturer> { match capture::shared() { Ok(c) => { eprintln!("GPU: {} ({}x MSAA)", c.adapter_name(), c.samples()); Some(c) } Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { eprintln!("skipping: {e}"); None } Err(e) => panic!("{e}"), } } /// RGBA pixels as `[r, g, b, a]`. fn pixels(img: &[u8]) -> impl Iterator + '_ { img.as_chunks::<4>().0.iter().copied() } fn session(script: &[(&str, serde_json::Value)]) -> Session { let mut s = Session::default(); for (c, p) in script { s.execute(c, p).unwrap(); } s } /// Appearances show as the viewport shows them: a body's colour, a face's own look. #[test] fn pictures_show_appearances() { let Some(c) = capturer() else { return }; let s = session(&[ ("solid.box", json!({"length": 40, "width": 30, "height": 20})), ("solid.cylinder", json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})), ("appearance.assign", json!({"bodies": ["Body2"], "color": [220, 60, 40]})), ("appearance.assign", json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})), ]); let cam = view::home_camera(&s); let img = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); assert_eq!(img.len(), 200 * 140 * 4); let green = pixels(&img).filter(|p| p[1] > 120 && p[0] < 90 && p[2] < 110).count(); let red = pixels(&img).filter(|p| p[0] > 150 && p[1] < 90 && p[2] < 80).count(); assert!(green > 100 && red > 100, "green top {green}, red cylinder {red} pixels"); assert!(pixels(&img).all(|p| p[3] == 255), "the picture is opaque"); } /// The ground grid and the origin's axes each change the picture. #[test] fn guides_change_the_picture() { let Some(c) = capturer() else { return }; let s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}))]); let cam = view::home_camera(&s); let picture = |grid, axes| c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid, axes }), &cam, 160, 100).unwrap(); let (all, no_grid, no_axes, bare) = (picture(true, true), picture(false, true), picture(true, false), picture(false, false)); assert!(all != no_grid && all != no_axes && no_grid != bare && no_axes != bare); } /// Curved surfaces get a dark silhouette where they turn away (the viewport's outline). #[test] fn pictures_draw_silhouettes_on_curved_surfaces() { let Some(c) = capturer() else { return }; let s = session(&[("solid.sphere", json!({"diameter": 20}))]); let cam = view::home_camera(&s); let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }), &cam, 400, 400).unwrap(); let dark = pixels(&img).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 95).count(); assert!(dark > 40, "silhouette pixels: {dark}"); } /// A section shows: the cut part is gone, the cut face hides what lies inside the body behind /// it and shows in the cap colour. #[test] fn pictures_show_the_section_and_its_cap() { let Some(c) = capturer() else { return }; let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]); let cam = view::home_camera(&s); let whole = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); // Cut away the front half (y < 15), so the iso camera looks into the cut. s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); let cut = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); let changed = pixels(&whole).zip(pixels(&cut)).filter(|(a, b)| a != b).count(); assert!(changed > 500, "{changed} pixels changed"); for fov in [0.0, 0.6] { let mut cam = view::home_camera(&s); cam.fov = fov; let mut sc = view::scene(&s, &cam); // A marker inside the body, behind the cut face. let red = Rgb(255, 0, 0); sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false }); let img = c.rgba(&sc, &cam, 300, 220).unwrap(); let marker = pixels(&img).filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face"); // The cap: warm (red over blue), unlike the grey-blue body and background. let cap = pixels(&img).filter(|p| i32::from(p[0]) - i32::from(p[2]) > 20).count(); assert!(cap > 500, "fov {fov}: cap pixels {cap}"); } } /// Surface analysis shades headless pictures as it shades the viewport: a box's flat faces in /// the curvature map's flat colour, zebra stripes in black and white, the accessibility analysis /// in reachable and blocked colours. #[test] fn pictures_show_the_surface_analysis() { let Some(c) = capturer() else { return }; let mut s = session(&[ ("solid.box", json!({"length": 60, "width": 40, "height": 20})), ("solid.hole", json!({"position": [30, 20, 20], "diameter": 10, "depth": 20})), ]); let cam = view::home_camera(&s); let picture = |s: &Session| c.rgba(&view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }), &cam, 320, 200).unwrap(); let plain = picture(&s); s.execute("inspect.curvature_map", &json!({})).unwrap(); let curvature = picture(&s); let blue = |img: &[u8]| pixels(img).filter(|p| i32::from(p[2]) - i32::from(p[0]) > 120 && p[1] < 140).count(); assert!(blue(&curvature) > 3000 && blue(&plain) == 0, "flat faces in the flat colour: {} (plain {})", blue(&curvature), blue(&plain)); s.execute("inspect.zebra", &json!({})).unwrap(); let zebra = picture(&s); let black = pixels(&zebra).filter(|p| p[0] < 40 && p[1] < 40 && p[2] < 40).count(); let white = pixels(&zebra).filter(|p| p[0] > 225 && p[1] > 225 && p[2] > 225).count(); assert!(black > 500 && white > 500, "zebra: {black} black, {white} white pixels"); s.execute("inspect.accessibility", &json!({})).unwrap(); let access = picture(&s); let [ok, blocked] = colors_of(solidcraft_engine::render::AnalysisColors::DEFAULT.access); let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60); let (reachable, under) = (pixels(&access).filter(|p| near(*p, ok)).count(), pixels(&access).filter(|p| near(*p, blocked)).count()); assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels"); } fn colors_of(c: [[f32; 3]; 2]) -> [[u8; 3]; 2] { c.map(|rgb| rgb.map(|x| (x * 255.0).round() as u8)) } /// An empty scene is the background gradient: the top colour at the top, the bottom one at the /// bottom. #[test] fn an_empty_picture_is_the_background() { let Some(c) = capturer() else { return }; let sc = solidcraft_engine::render::Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; let img = c.rgba(&sc, &Camera::default(), 64, 64).unwrap(); let at = |row: usize| pixels(&img).nth(row * 64 + 32).unwrap_or_default(); let near = |p: [u8; 4], c: Rgb| [p[0], p[1], p[2]].iter().zip([c.0, c.1, c.2]).all(|(a, b)| a.abs_diff(b) <= 3); assert!(near(at(0), colors::BG_TOP) && near(at(63), colors::BG_BOTTOM), "{:?} {:?}", at(0), at(63)); } /// Sizes outside 1 to the device's limit are errors, not crashes. #[test] fn bad_picture_sizes_are_errors() { let Some(c) = capturer() else { return }; let sc = solidcraft_engine::render::Scene::default(); for (w, h) in [(0, 10), (10, 0), (capture::MAX_SIDE + 1, 10), (u32::MAX, u32::MAX)] { assert!(c.rgba(&sc, &Camera::default(), w, h).is_err(), "{w} x {h}"); } // And the device still draws. assert!(c.rgba(&sc, &Camera::default(), 8, 8).is_ok()); }