Files
solvecraft/crates/gpu/tests/depth.rs
T
jyviko 88736dc9e4 Wave 259/509: thread: main's screw direction (#4414) out; the thread's hand is the one direction (and 2 more)
3 commits, squashed; the originals stay reachable from tag archive/merge-main-develop.

- a57ce13e7 jyviko: thread: a shaft thicker than a modelled thread is refused, never turned down
- 6b9f5e0e4 jyviko: thread: main's screw direction (#4414) out; the thread's hand is the one direction
- 5fc160732 jyviko: Main's new callers of the placing calls that now return their error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-26 19:21:58 -04:00

451 lines
24 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, with a section shown or not, and a
//! section's cap hides what lies behind it (#6328). 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 }).unwrap(), 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).unwrap().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.
/// `section`: with a section that cuts nothing shown, so the section's own passes draw the faces.
fn front(mesh: Mesh, lines: Vec<SceneLine>, radius: f64, fov: f64, section: bool) -> (Scene, Camera) {
let clip = section.then_some((Vec3::X, 1e3));
let scene = Scene { meshes: vec![SceneMesh::solid(Arc::new(mesh), Rgb(160, 165, 175))], lines, radius, clip, ..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), alpha: 1.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, with a section shown
/// or not (#6328: the section's marking pass draws the faces unpushed, then pushes them).
#[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, section) in [0.0, 0.6].into_iter().flat_map(|fov| [(fov, false), (fov, true)]) {
let (on, cam) = front(sheet(tilt.to_radians()), vec![red_line(a, b, false)], radius, fov, section);
let (top, _) = front(sheet(tilt.to_radians()), vec![red_line(a, b, true)], radius, fov, section);
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}, section {section}: {shown} of {all} px drawn"
);
}
}
}
}
}
/// A selected edge's highlight (a 3 px core in a 6 px halo, drawn by `GpuScene::lifted_line`
/// in the highlight scene) lying on a face seen almost edge on draws its whole width: the face
/// no longer buries part of the band, which showed as a thin wedge instead of a line. The same
/// band 5 mm behind a face seen square, beside faces seen square, stays hidden.
#[test]
fn a_selected_edge_on_a_grazing_face_draws_whole() {
let highlight = |a: Vec3, b: Vec3, n: Vec3| {
let mut sc = solidcraft_gpu::view::GpuScene::default();
let red = sc.color([255, 0, 0, 255]);
sc.lifted_line(a.to_f32(), b.to_f32(), red, 6.0, [n.to_f32(), n.to_f32()]);
sc
};
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 6.0, on_top: true }];
for (mode, c) in capturers() {
for tilt in [60.0_f64, 80.0, 85.0, 88.0] {
let tr = tilt.to_radians();
let t = tr.tan();
let n = Vec3::new(0.0, -tr.cos(), tr.sin());
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 (plain, cam) = front(sheet(tr), Vec::new(), radius, fov, false);
let (top, _) = front(sheet(tr), reference(a, b), radius, fov, false);
let shown = pixels(&c.rgba_highlighted(&plain, Some(highlight(a, b, n)), &cam, 200, 200).unwrap()).filter(|p| red(*p)).count();
let all = red_pixels(c, &top, &cam);
assert!(all > 100, "{mode}: the band itself ({all} px)");
assert!(shown * 10 >= all * 9, "{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}: {shown} of {all} px drawn");
}
}
}
for radius in [30.0, 17_000.0] {
for fov in [0.0, 0.6] {
let (a, b) = (Vec3::new(-12.0, 5.0, 5.0), Vec3::new(12.0, 5.0, 5.0));
let (plain, cam) = front(sheet(0.0), Vec::new(), radius, fov, false);
let img = c.rgba_highlighted(&plain, Some(highlight(a, b, Vec3::new(0.0, -1.0, 0.0))), &cam, 200, 200).unwrap();
let n = pixels(&img).filter(|p| red(*p)).count();
assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}: the band behind shows in {n} px");
}
}
}
}
/// An edge 0.5 mm behind a face seen square is hidden, however large the design, with a section
/// shown or not: 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, section) in [0.0, 0.6].into_iter().flat_map(|fov| [(fov, false), (fov, true)]) {
// 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, section);
let n = red_pixels(c, &scene, &cam);
assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}, section {section}: 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).unwrap();
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).unwrap();
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");
}
}
}
/// Pixels of the model's edges lying behind the section plane (recoloured red) that draw over
/// the cap: the cap's pixels are where the picture without lines shows the hatch (warm, with
/// warm pixels 3 px around, so the cut's outline doesn't count).
fn edges_through_the_cap(c: &Capturer, s: &Session, cam: &Camera, (origin, normal): (Vec3, Vec3), (w, h): (u32, u32)) -> usize {
let mut sc = view::scene(s, cam).unwrap();
let behind = |p: &Vec3| (*p - origin).dot(normal) < -0.5;
let segments = sc
.lines
.iter()
.flat_map(|l| l.points.windows(2).filter(|w| w.iter().all(behind)).map(|w| SceneLine { points: w.to_vec(), color: Rgb(255, 0, 0), ..*l }))
.collect();
sc.lines = segments;
let with = c.rgba(&sc, cam, w, h).unwrap();
sc.lines.clear();
let without = c.rgba(&sc, cam, w, h).unwrap();
let warm = |x: i64, y: i64| {
let at = (y * i64::from(w) + x) as usize * 4;
without.get(at..at + 3).is_some_and(|p| p[0] > 60 && p[0] > p[2].saturating_add(15))
};
let mut n = 0;
for y in 3..i64::from(h) - 3 {
for x in 3..i64::from(w) - 3 {
let at = (y * i64::from(w) + x) as usize * 4;
let cap = [(0, 0), (3, 0), (-3, 0), (0, 3), (0, -3)].iter().all(|(dx, dy)| warm(x + dx, y + dy));
// Redder than the cap there: a red line, whole or blended at its sides.
let redness = |img: &[u8]| img.get(at..at + 3).map_or(0, |p| i32::from(p[0]) - i32::from(p[1]));
if cap && redness(&with) > redness(&without) + 30 {
n += 1;
}
}
}
n
}
/// The cap hides the edges inside a cut body (#6328). Where a face seen through the cut meets a
/// face turned toward the eye along an edge (a pocket's front wall and its side wall or floor),
/// the faces' push left the cap out beside the edge, and the edge showed through it. A pocket
/// whose front wall is 2 mm behind the cut, in perspective and orthographic.
#[test]
fn edges_of_a_pocket_behind_the_cut_stay_under_the_cap() {
let mut s = session(&[
("solid.box", json!({"length": 40, "width": 40, "height": 30, "corner": [-20, -20, 0]})),
("solid.box", json!({"length": 16, "width": 16, "height": 18, "corner": [-14, -14, 12], "operation": "cut"})),
]);
let plane = (Vec3::new(0.0, -16.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
s.execute("inspect.section", &json!({"plane": {"origin": [0, -16, 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).unwrap();
cam.fov = fov;
let n = edges_through_the_cap(c, &s, &cam, plane, (360, 300));
assert!(n <= 2, "{mode} depth, fov {fov}: the pocket's edges show through the cap in {n} px");
}
}
}
/// The same while the view turns: a filleted hub with a bore and a cross hole, cut through the
/// middle, seen from twelve directions. Its fillets' and holes' edges came and went through the
/// cap from one step to the next.
#[test]
fn edges_of_a_cut_hub_stay_under_the_cap_as_the_view_turns() {
let mut s = session(&[
("solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "hub"})),
("solid.fillet", json!({"edges": [[20, 0, 40], [20, 0, 0]], "radius": 4})),
("solid.cylinder", json!({"radius": 8, "height": 30, "base": [0, 0, 10], "operation": "cut"})),
("solid.cylinder", json!({"radius": 3, "height": 50, "base": [-25, 0, 22], "axis": "X", "operation": "cut"})),
("solid.fillet", json!({"edges": [[8, 0, 40]], "radius": 1.5})),
]);
let plane = (Vec3::new(0.0, -2.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
s.execute("inspect.section", &json!({"plane": {"origin": [0, -2, 0], "normal": [0, -1, 0]}})).unwrap();
for (mode, c) in capturers() {
for fov in [0.0, 0.6] {
let mut shown = Vec::new();
for i in 0..12 {
let mut cam = view::home_camera(&s).unwrap();
(cam.fov, cam.yaw, cam.pitch) = (fov, -1.2 + 0.075 * f64::from(i), 0.35);
shown.push(edges_through_the_cap(c, &s, &cam, plane, (900, 760)));
}
let total: usize = shown.iter().sum();
assert!(total <= 6, "{mode} depth, fov {fov}: edges show through the cap, px per view {shown:?}");
}
}
}
/// #6328, bherbruck's block: 40 × 40 × 30 mm with a hole and a pocket, cut on XZ at y = -16, 2 mm in
/// front of the pocket and 12 mm in front of the hole, seen from the home view. The cap hides them:
/// it matches the plain block's cap. With the faces' slope push on the section's marking pass
/// (#1768), the pocket's and the hole's edges showed through it as dashed lines in perspective, 84
/// of the 18,328 cap pixels here (both depths); before #1768 and with the fix, none. Away from the
/// cut the plain block's picture is a box of just its kept part's: the push also let back faces win
/// a band along the far edges, which the cap hatched (58 px here in orthographic, 534 in
/// perspective; now 0 and 1). The limits leave room for an edge's exact tie, which rounding
/// settles per sample (one pixel on WARP).
#[test]
fn a_cut_hides_the_edges_behind_it() {
let block = [
("sketch.create", json!({"plane": "XY", "name": "Base"})),
("sketch.line", json!({"points": [[-20, -20], [20, -20], [20, 20], [-20, 20]], "closed": true})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 30, "body_name": "Block"})),
];
let behind = [
("sketch.create", json!({"plane": "XY", "name": "Hole"})),
("sketch.circle.center", json!({"center": [10, 0], "diameter": 8})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 30, "operation": "cut"})),
("sketch.create", json!({"plane": "XY", "name": "Pocket", "offset": 30})),
("sketch.line", json!({"points": [[-14, -14], [2, -14], [2, 2], [-14, 2]], "closed": true})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 18, "direction": "negative", "operation": "cut"})),
];
let cut = ("inspect.section", json!({"plane": "XZ", "at": -16, "flip": true}));
let plain = session(&[&block[..], std::slice::from_ref(&cut)].concat());
let cut_through = session(&[&block[..], &behind[..], &[cut]].concat());
let (w, h) = (400, 300);
for (mode, c) in capturers() {
for fov in [0.0, 0.6] {
let mut cam = view::home_camera(&plain).unwrap();
cam.fov = fov;
let radius = view::scene(&plain, &cam).unwrap().radius;
// The cap on screen: the cut's rectangle, its outline left out (2 px in).
let corner = |x: f64, z: f64| cam.to_screen(Vec3::new(x, -16.0, z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y));
let cap: Vec<(f64, f64)> = [(-20.0, 0.0), (20.0, 0.0), (20.0, 30.0), (-20.0, 30.0)].iter().filter_map(|(x, z)| corner(*x, *z)).collect();
assert_eq!(cap.len(), 4, "fov {fov}: the cut is on screen");
// Which way the corners run on screen, so a point's distance inside each edge is positive.
let turn = (0..4).map(|i| cap[i].0 * cap[(i + 1) % 4].1 - cap[(i + 1) % 4].0 * cap[i].1).sum::<f64>().signum();
let inside_by = |x: f64, y: f64, by: f64| {
let side = |i: usize| {
let ((ax, ay), (bx, by)) = (cap[i], cap[(i + 1) % 4]);
((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay)
};
(0..4).all(|i| side(i) * turn >= by)
};
let inside = |x: f64, y: f64| inside_by(x, y, 2.0);
// Without the ground grid, whose extent follows the design.
let shot = |s: &Session| c.rgba(&Scene { grid: Vec::new(), ..view::scene(s, &cam).unwrap() }, &cam, w, h).unwrap();
let (a, b) = (shot(&plain), shot(&cut_through));
let (mut shown, mut all) = (0, 0);
for (i, (p, q)) in pixels(&a).zip(pixels(&b)).enumerate() {
if inside((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5) {
all += 1;
shown += usize::from(p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 24));
}
}
assert!(all > 5000, "{mode}, fov {fov}: the cap ({all} px)");
assert!(shown <= 8, "{mode} depth, fov {fov}: {shown} of {all} cap px show what lies behind the cut");
// Away from the cut, the picture is a box of just the kept part's (no cap along the far
// edges either).
let kept = shot(&session(&[("solid.box", json!({"length": 40, "width": 36, "height": 30, "corner": [-20, -16, 0]}))]));
let fringe = pixels(&a)
.zip(pixels(&kept))
.enumerate()
.filter(|(i, (p, q))| {
!inside_by((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5, -8.0)
&& p.iter().zip(q).any(|(u, v)| u.abs_diff(*v) > 24)
})
.count();
assert!(fringe <= 8, "{mode} depth, fov {fov}: {fringe} px away from the cut differ from the kept part alone");
}
}
}