Files
solvecraft/crates/gpu/tests/depth.rs
T
Brazz 26f42ae09b Snapshot: draw a see-through appearance see-through, and show a tool body inside its target (#2329) (#2330)
* Snapshot: draw a see-through appearance see-through, and give the CLI --body, --tool and --section

A headless picture dropped a body's opacity: the scene carried only a colour, so a design with
a translucent appearance was drawn opaque by `solidcraft-cli snapshot`, by MCP screenshots and
in a design's thumbnail, although the window has always drawn it as glass. There was then no
way to look at the body a cut would remove inside the material it meets: only a volume to
measure and reason about, which hides exactly the errors a picture makes obvious (a tool that
stops short, or lands in the wrong place).

- `SceneMesh` carries the body's opacity; `view::build_scene` takes it from the design
  (`model_body_opacity`), and a selected body stays solid so the selection colour reads.
- The GPU capture routes a see-through body into the scene's glass at its own alpha, as the
  viewport does; the CPU rasterizer draws the solid bodies first (they own the depth buffer),
  then the see-through ones blended over them, far sides before near sides and without writing
  depth, so what lies inside shows and a line between the two is not hidden.
- `snapshot --body NAME:opacity=0.3,color=#rrggbb`, `--tool NAME` (that body solid in the tool
  colour, the rest see-through) and `--section z=27` run the commands the app itself runs
  (`appearance.assign`, `inspect.section`): the options are shortcuts, not a second renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tests: measure the tool colour as it is drawn through a see-through target

The tool-colour filter was derived on paper and demanded a red more than twice either other
channel, which the target in front of it tints away (measured: (200, 108, 85) through opacity
0.3); and the colour check painted the tool, which a solid target hides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fmt

* Snapshot options: strip the colour's # rather than slicing it; README

Slicing `value[1..]` on an input-derived string is what the house rules ask us not to write,
even where a length check has already made it safe. The error a failed assignment reports now
names the body rather than claiming it came from --body, since --tool reaches it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* --tool: assign the bodies around the tool in groups, not one command at a time

Each appearance.assign is an undo step with a document snapshot behind it, so one per body
made --tool cost grow with the size of the design for no reason: the bodies around the tool
all get the same opacity, and the ones that share a colour can be named in one call. A design
whose bodies all wear the default grey now costs two commands whatever its size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rasterizer: say where its glass and the GPU's differ

Splitting each see-through body by facing orders its own two layers; it does not sort across
bodies, which the GPU does. Worth stating rather than leaving a reader to find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:10:45 -04:00

239 lines
12 KiB
Rust
Raw 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::solid(Arc::new(mesh), Rgb(160, 165, 175))], 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");
}
}
}