Files

244 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Depth in headless pictures, drawn with the viewport's pipelines (#1434, #1739): an edge behind
//! a face stays hidden however large the design and however far the camera, and whatever lies on
//! a face (its edges, a thin label) draws over it at any angle. Each check runs with the depth the
//! device draws with (a 32-bit float buffer, reversed, where it has one) and with the 24-bit
//! fallback. Skipped, with the reason printed, without a GPU adapter (`SOLIDCRAFT_REQUIRE_GPU=1`
//! makes that a failure).
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::geom::{Mesh, Vec3};
use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine, SceneMesh, StandardView};
use solidcraft_engine::view::{self, Guides};
use solidcraft_gpu::GpuError;
use solidcraft_gpu::capture::{self, Capturer};
use std::sync::{Arc, OnceLock};
fn or_skip(c: Result<&'static Capturer, &'static GpuError>) -> Option<&'static Capturer> {
match c {
Ok(c) => Some(c),
Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("skipping: {e}");
None
}
Err(e) => panic!("{e}"),
}
}
/// The device's own depth, then the 24-bit fallback (the same device kind, its own instance).
fn capturers() -> Vec<(&'static str, &'static Capturer)> {
static FALLBACK: OnceLock<solidcraft_gpu::Result<Capturer>> = OnceLock::new();
let Some(own) = or_skip(capture::shared()) else { return Vec::new() };
let Some(fallback) = or_skip(FALLBACK.get_or_init(|| Capturer::headless_with_depth(wgpu::TextureFormat::Depth24PlusStencil8)).as_ref()) else {
return Vec::new();
};
eprintln!("GPU: {}, depth {:?} and {:?}", own.adapter_name(), own.depth_format(), fallback.depth_format());
vec![("own", own), ("24-bit", fallback)]
}
fn reversed(c: &Capturer) -> bool {
solidcraft_gpu::view::reversed_depth(Some(c.depth_format()))
}
/// RGBA pixels as `[r, g, b, a]`.
fn pixels(img: &[u8]) -> impl Iterator<Item = [u8; 4]> + '_ {
img.as_chunks::<4>().0.iter().copied()
}
fn red(p: [u8; 4]) -> bool {
p[0] > 150 && p[1] < 90 && p[2] < 90
}
fn session(script: &[(&str, serde_json::Value)]) -> Session {
let mut s = Session::default();
for (c, p) in script {
s.execute(c, p).unwrap();
}
s
}
fn picture(c: &Capturer, s: &Session, cam: &Camera, w: u32, h: u32) -> Vec<u8> {
c.rgba(&view::scene_with_guides(s, cam, None, Guides { grid: false, axes: false }), cam, w, h).unwrap()
}
fn differing(a: &[u8], b: &[u8]) -> usize {
pixels(a).zip(pixels(b)).filter(|(p, q)| p.iter().zip(q).any(|(x, y)| x.abs_diff(*y) > 24)).count()
}
/// The reproduction of #1434: a 20 mm plate with a washer 22–25 mm behind its front face, and a
/// 30 m slab far away that makes the design 34 m across. The washer never shows: the picture is
/// the same without it. (Lines were pulled toward the eye by 2e-4 of the depth range, about
/// 27 mm here in orthographic.)
#[test]
fn a_washer_behind_a_plate_stays_hidden_on_a_large_design() {
let plate = [
("solid.box", json!({"length": 30000, "width": 8000, "height": 100, "corner": [-15000, 3000, -3000], "body_name": "slab"})),
("solid.box", json!({"length": 200, "width": 20, "height": 200, "corner": [0, 0, 0], "body_name": "plate"})),
("solid.cylinder", json!({"radius": 8, "height": 60, "base": [100, -20, 100], "axis": "Y", "body_name": "bolt"})),
];
let washer = ("solid.cylinder", json!({"radius": 15, "height": 3, "base": [100, 22, 100], "axis": "Y", "body_name": "washer"}));
let without = session(&plate);
let with = session(&[plate[0].clone(), plate[1].clone(), plate[2].clone(), washer]);
for (mode, c) in capturers() {
for fov in [0.0, 0.6] {
let cam = Camera { target: Vec3::new(100.0, 10.0, 100.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 450.0, fov, ..Default::default() };
let n = differing(&picture(c, &with, &cam, 400, 300), &picture(c, &without, &cam, 400, 300));
assert_eq!(n, 0, "{mode} depth, fov {fov}: the washer shows through the plate in {n} pixels");
}
}
}
/// #1434's second reproduction, in perspective at 10 m: a 2 m plate, a 600 mm washer 12–15 mm
/// behind its front face, and a red label 0.3 mm thick on it. The washer stays hidden in both
/// depths; with reversed float depth the label is red all over (in 24 bits one depth step there
/// is about 0.6 mm, so the label speckled).
#[test]
fn at_ten_metres_a_thin_label_is_whole_and_a_washer_behind_stays_hidden() {
let base = [
("solid.box", json!({"length": 2000, "width": 10, "height": 2000, "corner": [0, 0, 0], "body_name": "plate"})),
("solid.box", json!({"length": 1000, "width": 0.3, "height": 400, "corner": [500, -0.3, 1400], "body_name": "label"})),
("appearance.assign", json!({"bodies": ["label"], "color": [204, 34, 34]})),
];
let washer = ("solid.cylinder", json!({"radius": 300, "height": 3, "base": [1000, 12, 1000], "axis": "Y", "body_name": "washer"}));
let without = session(&base);
let with = session(&[base[0].clone(), base[1].clone(), base[2].clone(), washer]);
let cam =
Camera { target: Vec3::new(1000.0, 5.0, 1000.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 10_000.0, fov: 0.6, ..Default::default() };
let (w, h) = (800, 600);
for (mode, c) in capturers() {
let img = picture(c, &with, &cam, w, h);
let n = differing(&img, &picture(c, &without, &cam, w, h));
assert_eq!(n, 0, "{mode} depth: the washer shows through the plate in {n} pixels");
if !reversed(c) {
continue;
}
// The label's front face (y = -0.3), sampled inside its outline.
let radius = view::scene(&with, &cam).radius;
let mut grey = Vec::new();
for i in 1..20 {
for j in 1..20 {
let p = Vec3::new(500.0 + 1000.0 * f64::from(i) / 20.0, -0.3, 1400.0 + 400.0 * f64::from(j) / 20.0);
let Some((x, y, _)) = cam.to_screen(p, f64::from(w), f64::from(h), radius) else { continue };
let at = (y as usize * w as usize + x as usize) * 4;
if let Some(px) = img.get(at..at + 4).and_then(|p| <[u8; 4]>::try_from(p).ok())
&& !red(px)
{
grey.push((x as u32, y as u32, px));
}
}
}
assert!(grey.is_empty(), "{mode} depth: the label speckles at {} of 361 points: {:?}", grey.len(), &grey[..grey.len().min(5)]);
}
}
/// A 30 × 40 mm sheet at y = z·tan(`tilt`) (facing the front camera when `tilt` is 0).
fn sheet(tilt: f64) -> Mesh {
let t = tilt.tan();
let c = [(-15.0, -20.0), (15.0, -20.0), (15.0, 20.0), (-15.0, 20.0)].map(|(x, z)| Vec3::new(x, z * t, z));
Mesh {
positions: c.to_vec(),
normals: vec![Vec3::new(0.0, -tilt.cos(), tilt.sin()); 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
tri_face: vec![0, 0],
..Default::default()
}
}
/// A front view of `mesh` with `lines`, as part of a design of `radius`, 40 mm high on 200 px.
fn front(mesh: Mesh, lines: Vec<SceneLine>, radius: f64, fov: f64) -> (Scene, Camera) {
let scene = Scene {
meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(160, 165, 175), face_colors: Vec::new() }],
lines,
radius,
..Default::default()
};
let mut cam = Camera { distance: if fov > 0.0 { 25.0 / (fov * 0.5).tan() } else { 62.5 }, fov, ..Default::default() };
cam.set_view(StandardView::Front);
(scene, cam)
}
fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine {
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 1.2, on_top }
}
fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize {
pixels(&c.rgba(scene, cam, 200, 200).unwrap()).filter(|p| red(*p)).count()
}
/// An edge lying on a face draws its whole width over it, on a face seen square and on one turned
/// to 80°, in orthographic and perspective, in a small design and a large one.
#[test]
fn an_edge_on_a_face_draws_whole_at_any_angle() {
for (mode, c) in capturers() {
for tilt in [0.0_f64, 60.0, 80.0] {
let t = tilt.to_radians().tan();
// Across the sheet, at z = 5: y = 5·tan(tilt).
let (a, b) = (Vec3::new(-12.0, 5.0 * t, 5.0), Vec3::new(12.0, 5.0 * t, 5.0));
for radius in [30.0, 17_000.0] {
for fov in [0.0, 0.6] {
let (on, cam) = front(sheet(tilt.to_radians()), vec![red_line(a, b, false)], radius, fov);
let (top, _) = front(sheet(tilt.to_radians()), vec![red_line(a, b, true)], radius, fov);
let (shown, all) = (red_pixels(c, &on, &cam), red_pixels(c, &top, &cam));
assert!(all > 50, "{mode}: the line itself ({all} px)");
assert!(shown * 10 >= all * 9, "{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}: {shown} of {all} px drawn");
}
}
}
}
}
/// An edge 0.5 mm behind a face seen square is hidden, however large the design: lines were pulled
/// toward the eye by a share of the depth range, about 27 mm in a design 34 m across.
#[test]
fn an_edge_just_behind_a_face_stays_hidden_on_a_large_design() {
for (mode, c) in capturers() {
for radius in [30.0, 17_000.0] {
for fov in [0.0, 0.6] {
// The sheet is at y = 0; the camera looks along +y, so behind is +y.
let (a, b) = (Vec3::new(-12.0, 0.5, 5.0), Vec3::new(12.0, 0.5, 5.0));
let (scene, cam) = front(sheet(0.0), vec![red_line(a, b, false)], radius, fov);
let n = red_pixels(c, &scene, &cam);
assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}: the edge behind shows in {n} px");
}
}
}
}
/// The application's device draws with reversed float depth where it can: every desktop backend
/// this runs on (D3D12, Vulkan, Metal) has `Depth32FloatStencil8`.
#[test]
fn the_device_draws_with_float_depth_where_it_has_it() {
let Some(c) = or_skip(capture::shared()) else { return };
eprintln!("GPU: {}, depth {:?}", c.adapter_name(), c.depth_format());
let float = c.depth_format() == wgpu::TextureFormat::Depth32FloatStencil8;
assert_eq!(float, reversed(c));
if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() {
assert!(float, "a desktop GPU without Depth32FloatStencil8: {:?}", c.depth_format());
}
}
/// What lies on a section's cut face draws over the cap, which writes its own depth (so the GPU's
/// push does not apply to it; the shader pushes it the same way).
#[test]
fn an_edge_on_the_cut_face_draws_over_the_cap() {
let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]);
// Cut away the front (y < 15), so the iso camera looks at the cut face at y = 15.
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
for (mode, c) in capturers() {
for fov in [0.0, 0.6] {
let mut cam = view::home_camera(&s);
cam.fov = fov;
let count = |on_top: bool| {
// The session's section (`scene_with_guides` takes its own). The line is 2 µm on
// the kept side: exactly on the plane, rounding cuts parts of it away with the rest.
let mut sc = view::scene(&s, &cam);
sc.lines.push(red_line(Vec3::new(5.0, 15.002, 10.0), Vec3::new(35.0, 15.002, 10.0), on_top));
pixels(&c.rgba(&sc, &cam, 300, 220).unwrap()).filter(|p| red(*p)).count()
};
let (shown, all) = (count(false), count(true));
assert!(all > 50, "{mode}: the line itself ({all} px)");
assert!(shown * 10 >= all * 9, "{mode} depth, fov {fov}: {shown} of {all} px drawn over the cap");
}
}
}