Files
jyviko dc38349cbf Layer split stage 1: the B-rep record, native body file and Vec/Plane come from solidcore-record (#11496)
* Kernel: the B-rep record and the native body file come from solidcore-record

SolidCraft's portable B-rep record (`solidcraft_geom::brep`: brep.rs, topology, history,
envelope, part, curve2) and the native body file (`solidcraft_kernel::native`: the tagged binary
codec and the container) move to solidcore's `solidcore-record` crate: geometry as data, with
structural validation and no maths (solidcore's docs/layers.md, stage 1). Every caller uses
`solidcore_record::brep` and `solidcore_record::native`; the old copies are deleted.

What computes from coordinates stays with the kernel, in geom's `brep`:
- `brep::check`: the record's geometric checks (orthonormal frames, non-degenerate planes,
  unit revolution axes, non-singular placements). `check::validate` and
  `check::validate_built` are the record's validation and these together, and every caller of
  `Brep::validate` uses them, so what is accepted is unchanged;
- `brep::label`: a surface's natural ranges (a cone's apex), `curve_interval`,
  `edge_curve_interval` and `form_turns`;
- `brep::pcurve::eval`: a pcurve's point;
- `geometry::check`, `placement::moved` and `split_edges` become functions on the record.

The kernel's native module keeps the conversion between a body and its saved record
(metadata.rs) and checks the decoded record's geometry. `native/analytic.rs`, which no module
declared, is deleted.

solidcore-record is pinned to its rev on solidcore's `layer-record` branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Geometry: Vec2, Vec3, Plane and the profile types come from solidcore-record

The plain data of vectors and points, sketch planes and planar profiles (`Seg2`, `Loop2`,
`Region2`) moves to `solidcore-record`, with its constants and component-wise operators only
(owner decision, solidcore layer split). Their geometry stays in geom as traits:
`Vec2Geom`, `Vec3Geom`, `PlaneGeom`, `Seg2Geom`, `Loop2Geom`, `Region2Geom`, `Seg2ArcFit`,
`Loop2ArcFit` and `Region2ArcFit`, in scope with `use solidcraft_geom::prelude::*`. Every caller
names the types from `solidcore_record`; geom no longer defines or re-exports them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Kernel: pin solidcore-record to solidcore main f5e8e9a29

solidcore-record is merged on solidcore main (tangency-labs/solidcore#2519); its crate there is
identical to the rev pinned before but for its clippy configuration. The lock is regenerated
against the merged rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:04:26 -05:00

413 lines
21 KiB
Rust

//! 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 solidcore_record::Vec3;
use solidcraft_engine::Session;
use solidcraft_engine::render::{Camera, Rgb, Scene, 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<Item = [u8; 4]> + '_ {
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
}
/// #2888: compiling the viewport on GL used to reject AO's depth textureLoad, even
/// with AO disabled. Select GL explicitly so a working Vulkan adapter cannot hide it.
#[test]
fn opengl_viewport_pipelines_render_a_model() {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::GL;
// NVIDIA's EGL answers wgpu's request for GL 3.3 with exactly 3.3 yet lists
// GL_ARB_compute_shader, so wgpu builds its indirect-call validation pipeline, naga cannot
// emit compute GLSL below 4.30, and request_device fails as Device(Lost). The test draws
// nothing indirect, so the validation is not needed here.
desc.flags.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
let instance = wgpu::Instance::new(desc);
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(e) => {
let required = std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() && wgpu::Backends::from_env() == Some(wgpu::Backends::GL);
assert!(!required, "OpenGL is required here: {e}");
eprintln!("skipping the OpenGL regression: {e}");
return;
}
};
assert_eq!(adapter.get_info().backend, wgpu::Backend::Gl);
eprintln!("OpenGL regression: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: solidcraft_gpu::depth_features(&adapter),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
.unwrap();
let c = Capturer::new(&adapter, device, queue).unwrap();
let s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]);
let cam = view::home_camera(&s).unwrap();
let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }).unwrap(), &cam, 160, 100).unwrap();
let empty = c.rgba(&Default::default(), &cam, 160, 100).unwrap();
let changed = pixels(&img).zip(pixels(&empty)).filter(|(a, b)| a != b).count();
assert!(changed > 500, "the model covers only {changed} pixels");
}
/// 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).unwrap();
let img = c.rgba(&view::scene(&s, &cam).unwrap(), &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 renderer counts what it draws (the performance overlay, #2548): every mesh triangle and
/// edge segment of the scene, more with more geometry, nothing with nothing to draw.
#[test]
fn the_renderer_counts_what_it_draws() {
let Some(c) = capturer() else { return };
let drawn = |s: &Session| {
let cam = view::home_camera(s).unwrap();
let scene = view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }).unwrap();
let triangles: usize = scene.meshes.iter().map(|m| m.mesh.triangles.len()).sum();
let (_, stats) = c.rgba_with_stats(&scene, &cam, 160, 100).unwrap();
(stats, triangles as u64)
};
let (empty, _) = drawn(&Session::default());
assert_eq!((empty.triangles, empty.line_segments), (0, 0), "{empty:?}");
let (one, one_mesh) = drawn(&session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]));
assert!(one_mesh >= 12 && one.triangles >= one_mesh, "{one:?} for {one_mesh} mesh triangles");
assert!(one.line_segments >= 12 && one.draw_calls > empty.draw_calls, "{one:?}");
let (two, two_mesh) = drawn(&session(&[
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("solid.cylinder", json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})),
]));
assert!(two_mesh > one_mesh && two.triangles >= two_mesh && two.triangles > one.triangles, "{two:?} for {two_mesh}");
assert!(two.line_segments > one.line_segments, "{two:?}");
}
/// Each picture's counts are its own while other threads draw on the same capturer: an empty
/// design never reports another thread's box, and a box never reports an empty design's
/// nothing. The counts were once read in a second call after the picture, and another thread's
/// picture could land in between.
#[test]
fn pictures_drawn_at_once_each_count_their_own() {
let Some(c) = capturer() else { return };
let scene = |s: &Session| {
let cam = view::home_camera(s).unwrap();
(view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }).unwrap(), cam)
};
let empty = scene(&Session::default());
let solid = scene(&session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]));
std::thread::scope(|t| {
for k in 0..8 {
let (name, (sc, cam), drawn) = if k % 2 == 0 { ("empty", &empty, false) } else { ("box", &solid, true) };
t.spawn(move || {
for i in 0..50 {
let (_, stats) = c.rgba_with_stats(sc, cam, 64, 48).unwrap();
assert_eq!(stats.triangles > 0, drawn, "{name}, picture {i}: {stats:?}");
}
});
}
});
}
/// 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).unwrap();
let picture = |grid, axes| c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid, axes }).unwrap(), &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).unwrap();
let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }).unwrap(), &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).unwrap();
let whole = c.rgba(&view::scene(&s, &cam).unwrap(), &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).unwrap(), &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).unwrap();
cam.fov = fov;
let mut sc = view::scene(&s, &cam).unwrap();
// The cap in the cap colour: a part's hatch colour is the one its name picks (#8529).
sc.part_hatch = None;
// 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,
alpha: 1.0,
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}");
}
}
/// With the section's hatch off, the cut shows plain in its body's own colour, as the CPU
/// rasterizer draws it; hatched, it is tinted and striped.
#[test]
fn pictures_show_a_plain_cut_without_the_hatch() {
let Some(c) = capturer() else { return };
let mut s = session(&[
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("appearance.assign", json!({"bodies": ["Body1"], "color": [40, 160, 90]})),
]);
let cam = view::home_camera(&s).unwrap();
let mut picture = |hatch: bool| {
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}, "hatch": hatch})).unwrap();
c.rgba(&view::scene(&s, &cam).unwrap(), &cam, 300, 220).unwrap()
};
let solid = |img: &[u8]| pixels(img).filter(|p| p[..3].iter().zip([40u8, 160, 90]).all(|(a, b)| a.abs_diff(b) <= 3)).count();
let (hatched, plain) = (picture(true), picture(false));
assert!(solid(&plain) > 500, "plain cut: {} pixels in the body's colour", solid(&plain));
assert!(solid(&hatched) < 50, "the hatched cut is tinted: {}", solid(&hatched));
}
/// A body inside another's hollow, behind the cut, leaves no trace on the cap (#6328): the cap
/// used to be painted by every face behind it, last drawn last, so the inner body's colour and
/// outline showed through the cut like an X-ray. Hatched and plain, orthographic and
/// perspective, the picture is the same with the inner body as without it.
#[test]
fn a_body_behind_the_cut_does_not_colour_the_cap() {
let Some(c) = capturer() else { return };
let outer = [
("solid.box", json!({"length": 40, "width": 40, "height": 30, "body_name": "outer"})),
("solid.box", json!({"length": 20, "width": 20, "height": 16, "corner": [10, 10, 7], "operation": "cut"})),
("appearance.assign", json!({"bodies": ["outer"], "color": [40, 160, 90]})),
];
let inner = [
("solid.box", json!({"length": 8, "width": 8, "height": 6, "corner": [16, 16, 12], "operation": "new_body", "body_name": "inner"})),
("appearance.assign", json!({"bodies": ["inner"], "color": [220, 30, 30]})),
];
let mut alone = session(&outer);
let mut with = session(&[outer[0].clone(), outer[1].clone(), outer[2].clone(), inner[0].clone(), inner[1].clone()]);
for hatch in [true, false] {
// Cut away y < 6: the hollow (and the inner body) lie 4 mm behind the cut face.
for s in [&mut alone, &mut with] {
s.execute("inspect.section", &json!({"plane": {"origin": [0, 6, 0], "normal": [0, -1, 0]}, "hatch": hatch})).unwrap();
}
for fov in [0.0, 0.6] {
let mut cam = view::home_camera(&alone).unwrap();
cam.fov = fov;
let picture = |s: &Session| c.rgba(&view::scene(s, &cam).unwrap(), &cam, 300, 220).unwrap();
let (a, b) = (picture(&alone), picture(&with));
let differ = pixels(&a).zip(pixels(&b)).filter(|(p, q)| p.iter().zip(q).any(|(x, y)| x.abs_diff(*y) > 8)).count();
assert_eq!(differ, 0, "hatch {hatch}, fov {fov}: the inner body shows through the cap in {differ} pixels");
}
}
}
/// #4214: a line that is not opaque is blended with what is under it, so the grid's fade reaches
/// the picture.
#[test]
fn pictures_blend_a_line_that_is_not_opaque() {
let Some(c) = capturer() else { return };
let cam = Camera { distance: 50.0, fov: 0.0, ..Default::default() };
let darkest = |alpha: f32| {
let mut sc = Scene { radius: 30.0, ..Default::default() };
sc.lines.push(SceneLine {
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
color: Rgb(0, 0, 0),
alpha,
width: 1.4,
on_top: false,
});
let img = c.rgba(&sc, &cam, 64, 64).unwrap();
pixels(&img).map(|p| i32::from(p[0])).min().unwrap_or(255)
};
let solid = darkest(1.0);
let half = darkest(0.5);
assert!(solid < 40, "the opaque line is black: {solid}");
assert!(half > 60 && half < 170, "the half-opaque line is between the background and the line: {half} against {solid}");
}
/// 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).unwrap();
let picture =
|s: &Session| c.rgba(&view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }).unwrap(), &cam, 320, 200).unwrap();
let plain = picture(&s);
s.execute("inspect.curvature_map", &json!({})).unwrap();
let curvature = picture(&s);
let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60);
let flat = solidcraft_engine::render::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
let flats = |img: &[u8]| pixels(img).filter(|p| near(*p, flat)).count();
// The flat colour is dark, so the plain picture's edge lines come near it in places.
assert!(
flats(&curvature) > 3000 && flats(&plain) * 10 < flats(&curvature),
"flat faces in the flat colour: {} (plain {})",
flats(&curvature),
flats(&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 (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");
}
/// A flat face of a box keeps its zebra colour while the view turns a little at a time (#2153):
/// a horizontal face reflected straight ahead, on the edge between a black and a white stripe,
/// and rounding flipped it from one step to the next.
#[test]
fn zebra_on_flat_faces_holds_still_while_the_view_turns() {
let Some(c) = capturer() else { return };
let mut s = session(&[("solid.box", json!({"length": 60, "width": 40, "height": 20}))]);
s.execute("inspect.zebra", &json!({})).unwrap();
let home = view::home_camera(&s).unwrap();
let whites: Vec<usize> = (0..24)
.map(|step| {
let mut cam = home;
cam.yaw += f64::from(step) * 0.11_f64.to_radians();
let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }).unwrap(), &cam, 320, 200).unwrap();
pixels(&img).filter(|p| p[0] > 225 && p[1] > 225 && p[2] > 225).count()
})
.collect();
let (least, most) = (whites.iter().min().copied().unwrap_or(0), whites.iter().max().copied().unwrap_or(0));
assert!(most > 1000, "white stripes: {whites:?}");
assert!((most - least) * 10 < most, "white pixels jump while the view turns by 0.11 degree steps: {whites:?}");
}
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.
let drawn = c.rgba(&sc, &Camera::default(), 8, 8);
assert!(drawn.is_ok(), "{:?}", drawn.err());
}
/// Pictures read back while another thread uses their device: the app's window submits its
/// frames on the device its thumbnails and `ui.render` pictures are drawn on, and on the shared
/// capturer every picture checks its device while another one reads back. Any submit or poll can
/// take a picture's finished read-back and answer it on its own thread a moment after the
/// picture's wait is over; the picture used to give up then ("reading a picture back without
/// waiting for the browser", now and then in this binary's other tests).
#[test]
fn pictures_read_back_while_another_thread_uses_the_device() {
use std::sync::atomic::{AtomicBool, Ordering};
let policy = solidcraft_gpu::select::Policy::from_env().unwrap();
let Some((adapter, _)) = solidcraft_gpu::select::pick(policy) else {
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "no GPU adapter under the {} policy", policy.name());
eprintln!("skipping: no GPU adapter under the {} policy", policy.name());
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: solidcraft_gpu::depth_features(&adapter),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
.unwrap();
let c = Capturer::new(&adapter, device.clone(), queue).unwrap();
let sc = solidcraft_engine::render::Scene::default();
const PICTURES: usize = 2000;
let done = AtomicBool::new(false);
let failed: Vec<String> = std::thread::scope(|t| {
t.spawn(|| {
while !done.load(Ordering::Relaxed) {
let _ = device.poll(wgpu::PollType::Poll);
}
});
let failed = (0..PICTURES).filter_map(|i| c.rgba(&sc, &Camera::default(), 8, 8).err().map(|e| format!("picture {i}: {e}"))).collect();
done.store(true, Ordering::Relaxed);
failed
});
assert!(failed.is_empty(), "{} of {PICTURES} pictures failed, the first: {:?}", failed.len(), failed.first());
}