Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 22:19:26 -04:00
1 parent 48322f6464
commit 1a0b33fcf3
54 files changed
+3525 -1998

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! How headless pictures of the model are drawn: a renderer the host installs. The engine
//! describes what a picture shows ([`crate::view::scene`]); drawing it is a GPU job the engine
//! cannot do itself (the GPU crate sits above it), so the application, the CLI and the MCP
//! server install one ([`install`]) that draws with the viewport's own pipelines (or, as a last
//! resort the host chooses, the CPU rasterizer). Every picture names the renderer that drew it.
//! Without a renderer, a picture is an error and a save simply has no thumbnail.
use std::sync::{Arc, RwLock};
use solidcraft_render::{Camera, Scene};
/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`,
/// `gpu-software` or `cpu`).
pub struct Picture {
pub rgba: Vec<u8>,
pub renderer: &'static str,
}
/// A picture as PNG bytes, and the renderer that drew it.
pub struct Png {
pub bytes: Vec<u8>,
pub renderer: &'static str,
}
/// What a reply says when the CPU rasterizer drew the picture.
pub const CPU_WARNING: &str = "drawn by the CPU rasterizer (no GPU adapter qualified, or SOLIDCRAFT_GPU=cpu): surface analysis colours, highlights and legends are not drawn; `solidcraft-cli gpu` lists the adapters";
/// The warning that goes with a picture drawn by `renderer`, if any.
pub fn warning(renderer: &str) -> Option<&'static str> {
(renderer == "cpu").then_some(CPU_WARNING)
}
/// Draws `scene` seen by `cam` at `width` × `height` pixels (`width * height * 4` bytes of RGBA),
/// or says why it could not.
pub type Renderer = dyn Fn(&Scene, &Camera, u32, u32) -> Result<Picture, String> + Send + Sync;
static RENDERER: RwLock<Option<Arc<Renderer>>> = RwLock::new(None);
/// Make `renderer` draw this process's headless pictures (replacing any installed before).
pub fn install(renderer: Arc<Renderer>) {
if let Ok(mut r) = RENDERER.write() {
*r = Some(renderer);
}
}
/// The installed renderer, if any.
pub fn installed() -> Option<Arc<Renderer>> {
RENDERER.read().ok().and_then(|r| r.clone())
}
/// `scene` drawn by the installed renderer.
pub fn rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
rgba_with(installed().as_deref(), scene, cam, width, height)
}
/// `scene` drawn by the installed renderer, as PNG.
pub fn png(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
png_with(installed().as_deref(), scene, cam, width, height)
}
/// [`rgba`] with `renderer` instead of the installed one.
pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
let renderer = renderer.ok_or("no renderer is installed to draw pictures in this process")?;
let picture = renderer(scene, cam, width, height)?;
if picture.rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
return Err(format!("the renderer returned {} bytes for {width} × {height} pixels", picture.rgba.len()));
}
if let Some(w) = warning(picture.renderer) {
log::warn!("{w}");
}
Ok(picture)
}
/// [`png`] with `renderer` instead of the installed one.
pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?;
let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
Ok(Png { bytes, renderer })
}
/// For tests: a stand-in for the renderer the hosts install, a flat picture whose first byte
/// counts the scene's meshes.
#[cfg(test)]
pub(crate) fn install_test_renderer() {
install(Arc::new(|scene: &Scene, _: &Camera, w: u32, h: u32| {
let mut rgba = vec![200; (w * h * 4) as usize];
if let Some(first) = rgba.first_mut() {
*first = scene.meshes.len() as u8;
}
Ok(Picture { rgba, renderer: "gpu" })
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_a_renderer_a_picture_is_an_error_not_a_crash() {
let e = png_with(None, &Scene::default(), &Camera::default(), 16, 16).err().unwrap();
assert!(e.contains("no renderer"), "{e}");
}
#[test]
fn a_renderer_that_returns_the_wrong_size_is_an_error() {
let short: &Renderer = &|_, _, _, _| Ok(Picture { rgba: vec![0; 3], renderer: "gpu" });
assert!(rgba_with(Some(short), &Scene::default(), &Camera::default(), 2, 2).is_err());
let plain: &Renderer = &|_, _, w, h| Ok(Picture { rgba: vec![255; (w * h * 4) as usize], renderer: "cpu" });
let png = png_with(Some(plain), &Scene::default(), &Camera::default(), 2, 2).unwrap();
assert!(png.bytes.starts_with(b"\x89PNG"));
assert_eq!(png.renderer, "cpu");
assert_eq!((warning("cpu"), warning("gpu"), warning("gpu-software")), (Some(CPU_WARNING), None, None));
}
}
+2
View File
@@ -738,6 +738,8 @@ mod tests {
#[test]
fn save_and_save_as_require_explicit_native_cache_opt_in() {
// Thumbnails are drawn by the renderer the host installs.
crate::capture::install_test_renderer();
let mut session = Session::new(Document::new("Cache opt-in"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
+26 -62
View File
@@ -4,6 +4,15 @@ use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
let (scene, camera) = scene(s)?;
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
// Drawn by the renderer the host installed (the GPU); none installed, no thumbnail.
let renderer = crate::capture::installed()?;
crate::capture::png_with(Some(&*renderer), &scene, &camera, u32::try_from(width).ok()?, u32::try_from(height).ok()?).ok().map(|p| p.bytes)
}
/// What the thumbnail shows: the shown bodies' warm display meshes in their colours, iso, fitted.
fn scene(s: &Session) -> Option<(Scene, Camera)> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
@@ -38,76 +47,16 @@ pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
camera.distance *= 0.7;
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
if !within_raster_budget(&scene, &camera, width, height) {
return None;
}
let image = solidcraft_render::render(&scene, &camera, width, height);
solidcraft_render::png(width, height, image.rgba)
}
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
// 2x supersampling; conservatively count each projected triangle's pixel box.
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
let mut pixels = 0usize;
let mut colors = 0usize;
for entry in &scene.meshes {
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
if colors > 1_000_000 {
return false;
}
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
for point in &entry.mesh.positions {
let q = matrix.apply(*point);
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
return false;
}
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
if pixel.iter().any(|x| !x.is_finite()) {
return false;
}
projected.push(pixel);
}
for triangle in &entry.mesh.triangles {
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
return false;
};
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
pixels = pixels.saturating_add(dx.saturating_mul(dy));
if pixels > 4_000_000 {
return false;
}
}
}
true
Some((scene, camera))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn portable_thumbnail_refuses_excessive_overdraw() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let mesh = session.world_state().bodies[0].mesh();
let bounds = mesh.bounds();
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
assert!(within_raster_budget(&scene, &camera, 240, 150));
let mut repeated = (*mesh).clone();
repeated.triangles = vec![repeated.triangles[0]; 100_000];
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
assert!(!within_raster_budget(&scene, &camera, 240, 150));
}
#[test]
fn portable_thumbnail_preserves_saved_visibility() {
crate::capture::install_test_renderer();
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
@@ -116,6 +65,7 @@ mod tests {
for body in &world.bodies {
body.mesh();
}
assert_eq!(scene(&session).unwrap().0.meshes.len(), 1, "the hidden body is not in the picture");
let expected = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let doc = solidcraft_io::read_design(&bytes).unwrap();
@@ -131,6 +81,7 @@ mod tests {
#[test]
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
crate::capture::install_test_renderer();
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
@@ -147,4 +98,17 @@ mod tests {
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
/// Without a renderer installed (a browser, a tool that draws nothing) a save has no
/// thumbnail and does not fail.
#[test]
fn without_a_renderer_a_save_has_no_thumbnail() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
for body in &session.world_state().bodies {
body.mesh();
}
let (scene, camera) = scene(&session).unwrap();
assert!(crate::capture::png_with(None, &scene, &camera, 240, 150).is_err());
}
}
+15
View File
@@ -7,6 +7,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod capture;
pub mod cmd;
pub mod control_auth;
pub mod crash;
@@ -290,6 +291,20 @@ pub enum SurfaceAnalysis {
Access { dir: Vec3 },
}
impl SurfaceAnalysis {
/// How a picture shades the faces for this analysis.
pub fn mode(self) -> render::AnalysisMode {
use render::AnalysisMode as M;
match self {
SurfaceAnalysis::Zebra { stripes } => M::Zebra { stripes },
SurfaceAnalysis::Draft { pull, angle } => M::Draft { pull, angle },
SurfaceAnalysis::Curvature { radius } => M::Curvature { radius },
SurfaceAnalysis::Environment => M::Environment,
SurfaceAnalysis::Access { dir } => M::Access { dir },
}
}
}
const MAX_UNDO: usize = 200;
/// Checkpoints kept (the oldest go first).
const MAX_CHECKPOINTS: usize = 16;
+58 -32
View File
@@ -5,7 +5,7 @@
use std::sync::Arc;
use solidcraft_geom::{Aabb3, Plane, Vec3};
use solidcraft_render::{Camera, Rgb, Scene, SceneLine, SceneMesh};
use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
use solidcraft_sketch::Sketch;
use crate::{Sel, Session};
@@ -68,6 +68,8 @@ pub struct Palette {
pub axes: [Rgb; 3],
/// What the section cap is tinted toward.
pub cap: Rgb,
/// The surface analysis colours.
pub analysis: AnalysisColors,
}
impl Palette {
@@ -89,6 +91,7 @@ impl Palette {
sketch_centerline: colors::SKETCH_CENTERLINE,
axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z],
cap: colors::SECTION_CAP,
analysis: AnalysisColors::DEFAULT,
};
}
@@ -362,10 +365,17 @@ fn build_scene(
) -> Scene {
let st = s.world_state();
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
let mut sc = Scene { background: Some(p.background), clip: section.map(|(o, n)| (n, n.dot(o))), cap: Some(p.cap), ..Default::default() };
let mut sc = Scene {
background: Some(p.background),
clip: section.map(|(o, n)| (n, n.dot(o))),
cap: Some(p.cap),
analysis: s.analysis.map(|a| SceneAnalysis { mode: a.mode(), colors: p.analysis }),
..Default::default()
};
let b = bounds(s);
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
// Grid on the ground plane (XY with Z up, XZ with Y up).
// Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the
// viewport.
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
@@ -375,19 +385,12 @@ fn build_scene(
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
let col = if (t / major).fract().abs() < 1e-9 { p.grid_major } else { p.grid };
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
let major_line = (t / major).fract().abs() < 1e-9;
let col = if major_line { p.grid_major } else { p.grid };
// The viewport's widths (pixels).
let width = if major_line { 1.0 } else { 0.7 };
sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], color: col, width, on_top: false });
sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], color: col, width, on_top: false });
}
}
// The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport.
@@ -583,8 +586,8 @@ mod tests {
use super::*;
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
/// faces with looks of their own.
/// Headless pictures show appearances as the viewport does: a body's colour and faces with
/// looks of their own (the picture itself: `solidcraft-gpu`'s capture tests).
#[test]
fn snapshot_scene_carries_appearances() {
let mut s = Session::default();
@@ -596,9 +599,6 @@ mod tests {
let sc = scene(&s, &cam);
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
// And the rendered picture has the green top.
let img = solidcraft_render::render(&sc, &cam, 200, 140);
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
}
/// A render can leave out the ground grid and the origin's axes (a box straddling Z 0 is cut
@@ -610,7 +610,7 @@ mod tests {
let cam = home_camera(&s);
let grid = [colors::GRID, colors::GRID_MAJOR];
let axes = [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z];
let count = |sc: &Scene, colours: &[Rgb]| sc.lines.iter().filter(|l| colours.contains(&l.color)).count();
let count = |sc: &Scene, colours: &[Rgb]| sc.grid.iter().chain(&sc.lines).filter(|l| colours.contains(&l.color)).count();
let all = scene(&s, &cam);
let (grid_lines, edges) = (count(&all, &grid), count(&all, &[colors::EDGE]));
assert!(grid_lines > 20 && edges >= 12, "{grid_lines} grid lines, {edges} edges");
@@ -622,10 +622,7 @@ mod tests {
let no_axes = render(Guides { grid: true, axes: false });
assert_eq!((count(&no_axes, &grid), count(&no_axes, &axes), count(&no_axes, &[colors::EDGE])), (grid_lines, 0, edges));
let bare = render(Guides { grid: false, axes: false });
assert_eq!(bare.lines.len(), edges);
// The picture changes with each.
let png = |sc: &Scene| solidcraft_render::render_png(sc, &cam, 160, 100);
assert!(png(&all) != png(&no_grid) && png(&all) != png(&no_axes) && png(&no_grid) != png(&bare));
assert_eq!((bare.lines.len(), bare.grid.len()), (edges, 0));
}
/// An origin axis hidden with `browser.visibility` stays out of headless renders, as it does
@@ -646,14 +643,43 @@ mod tests {
s.execute("browser.visibility", &json!({"folder": "origin", "visible": false})).unwrap();
assert!(shown(&s).is_empty());
let sc = scene_with_guides(&s, &cam, None, Guides { grid: true, axes: true });
assert!(sc.lines.iter().any(|l| l.color == colors::GRID), "the grid is not part of the origin");
assert!(sc.grid.iter().any(|l| l.color == colors::GRID), "the grid is not part of the origin");
s.execute("browser.visibility", &json!({"folder": "origin", "visible": true})).unwrap();
assert_eq!(shown(&s), axes);
}
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
/// A section cuts the headless scene: the clip plane and the cap colour, and the outline
/// where the plane cuts each body (the picture: `solidcraft-gpu`'s capture tests).
#[test]
fn snapshots_draw_silhouettes_on_curved_surfaces() {
fn the_section_cuts_the_scene() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let cam = home_camera(&s);
assert!(scene(&s, &cam).clip.is_none());
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
let sc = scene(&s, &cam);
let (n, d) = sc.clip.unwrap();
assert!((n - Vec3::new(0.0, -1.0, 0.0)).len() < 1e-12 && (d + 15.0).abs() < 1e-12, "{n:?} {d}");
assert_eq!(sc.cap, Some(colors::SECTION_CAP));
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
}
/// The surface analysis shades the headless scene too, in the palette's colours.
#[test]
fn the_surface_analysis_shades_the_scene() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let cam = home_camera(&s);
assert!(scene(&s, &cam).analysis.is_none());
s.analysis = Some(crate::SurfaceAnalysis::Curvature { radius: 5.0 });
let a = scene(&s, &cam).analysis.unwrap();
assert_eq!((a.mode, a.colors), (solidcraft_render::AnalysisMode::Curvature { radius: 5.0 }, Palette::DEFAULT.analysis));
}
/// The CPU rasterizer (the last tier of headless pictures, `SOLIDCRAFT_GPU=cpu` or no adapter
/// under `auto`): curved surfaces get a dark silhouette line where they turn away.
#[test]
fn cpu_pictures_draw_silhouettes_on_curved_surfaces() {
let mut s = Session::default();
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
let cam = home_camera(&s);
@@ -662,9 +688,9 @@ mod tests {
assert!(dark > 40, "silhouette pixels: {dark}");
}
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
/// The CPU rasterizer: a section shows; the cut part is gone and the cut face is drawn.
#[test]
fn snapshots_show_the_section() {
fn cpu_pictures_show_the_section() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let cam = home_camera(&s);
@@ -681,7 +707,7 @@ mod tests {
/// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and
/// the cut's outline is drawn.
#[test]
fn snapshot_section_caps_hide_the_inside() {
fn cpu_picture_section_caps_hide_the_inside() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
// Cut away the front half (y < 15), so the iso camera looks into the cut.
+13 -4
View File
@@ -24,6 +24,12 @@ const MARK: Palette = Palette {
sketch_centerline: Rgb(43, 44, 45),
axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)],
cap: Rgb(55, 56, 57),
analysis: solidcraft_render::AnalysisColors {
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]],
hatch: true,
},
};
/// Each curve's lines at x = its index, so a line tells which curve it draws.
@@ -49,6 +55,7 @@ fn the_default_palette_is_the_colour_table() {
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
);
assert_eq!((p.axes, p.cap), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP));
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
}
#[test]
@@ -154,11 +161,13 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
MARK.sketch_construction,
MARK.sketch_centerline,
];
let stray: Vec<Rgb> = marked.lines.iter().map(|l| l.color).filter(|c| !allowed.contains(c)).collect();
let all_lines = || marked.grid.iter().chain(&marked.lines);
let stray: Vec<Rgb> = all_lines().map(|l| l.color).filter(|c| !allowed.contains(c)).collect();
assert!(stray.is_empty(), "line colours not from the palette: {stray:?}");
for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done] {
assert!(marked.lines.iter().any(|l| l.color == want), "no line in {want:?}");
assert!(all_lines().any(|l| l.color == want), "no line in {want:?}");
}
assert!(marked.grid.iter().all(|l| [MARK.grid, MARK.grid_major].contains(&l.color)), "only the grid is drawn under the model");
assert_eq!(mesh(&marked, "Grey").0, colors::BODY, "an appearance equal to the default grey is the design's");
assert_eq!(mesh(&marked, "Picked").0, MARK.body_selected);
let (plain, faces) = mesh(&marked, "Plain");
@@ -172,8 +181,8 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
(default.background, default.cap, default.clip, default.radius),
(plain_scene.background, plain_scene.cap, plain_scene.clip, plain_scene.radius)
);
assert_eq!(default.lines.len(), plain_scene.lines.len());
for (a, b) in default.lines.iter().zip(&plain_scene.lines) {
assert_eq!((default.lines.len(), default.grid.len()), (plain_scene.lines.len(), plain_scene.grid.len()));
for (a, b) in default.lines.iter().zip(&plain_scene.lines).chain(default.grid.iter().zip(&plain_scene.grid)) {
assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top));
}
for name in &names {
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-gpu"
description = "SolidCraft GPU compute: evaluates display tessellation plans (surface points and normals) in a compute shader"
description = "SolidCraft GPU rendering: the viewport's render pipelines, offscreen captures, and display tessellation plans evaluated in a compute shader"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,9 +10,11 @@ workspace = true
[dependencies]
solidcraft-geom = { workspace = true }
solidcraft-render = { workspace = true }
wgpu = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
pollster = { workspace = true }
@@ -21,5 +23,8 @@ wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
[dev-dependencies]
solidcraft-kernel = { workspace = true }
# Headless pictures of real designs (capture tests).
solidcraft-engine = { workspace = true }
serde_json = { workspace = true }
# The HLSL the D3D12 backend compiles (tests check it suits FXC).
naga = { workspace = true, features = ["wgsl-in", "hlsl-out"] }
+428
View File
@@ -0,0 +1,428 @@
//! Pictures of a [`Scene`] drawn offscreen with the viewport's pipelines ([`crate::view`]): the
//! same shading, edges, silhouettes, section cap and surface analysis the viewport shows, read
//! back as RGBA. `solidcraft-cli snapshot`, MCP screenshots, `ui.render` and thumbnails draw
//! through it, on the application's device ([`Capturer::new`]) or on a headless one
//! ([`Capturer::headless`], [`shared`]). Without a qualifying adapter, the `auto` policy falls
//! to the last tier, the CPU rasterizer ([`headless_rgba`]), and says so: every picture names the
//! renderer that drew it (`gpu`, `gpu-software` or `cpu`); `hardware` and `software` fail with an
//! error that names the policy.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use solidcraft_geom::Vec3;
use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene};
use wgpu::util::DeviceExt;
use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform};
use crate::{GpuError, Result, popped};
/// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual
/// (non-sRGB) target.
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
/// Samples per pixel (MSAA), as the desktop viewport's default; 1 where the device has no 4×.
pub const SAMPLES: u32 = 4;
/// The largest picture side (pixels); the device's texture limit may lower it.
pub const MAX_SIDE: u32 = 8192;
/// The background of a scene without one of its own: the light theme's viewport gradient.
const BACKGROUND: (Rgb, Rgb) = (Rgb(236, 240, 245), Rgb(205, 212, 222));
/// How long a picture may take on the GPU before it is given up.
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// The viewport's pipelines on one device, drawing pictures offscreen. Pictures are drawn one at
/// a time (callers on several threads take turns).
pub struct Capturer {
device: wgpu::Device,
queue: wgpu::Queue,
adapter: String,
/// The adapter is a GPU driver running on the CPU (llvmpipe, lavapipe, WARP).
software: bool,
samples: u32,
max_side: u32,
state: Mutex<State>,
}
struct State {
renderer: Renderer,
background: wgpu::RenderPipeline,
/// The last picture's scene key and accessibility map version (each picture uploads anew).
key: u64,
access_version: u64,
}
impl Capturer {
/// The pipelines on an existing device (the application's).
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<Self> {
let flags = |f: wgpu::TextureFormat| adapter.get_texture_format_features(f).flags;
let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(DEPTH).sample_count_supported(SAMPLES) { SAMPLES } else { 1 };
let scopes = error_scopes(&device);
let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(DEPTH), samples, tess: false }, None);
let background = background_pipeline(&device, samples);
caught(scopes).map_or(Ok(()), |e| Err(GpuError::Pipeline(e)))?;
let max_side = device.limits().max_texture_dimension_2d.min(MAX_SIDE);
Ok(Capturer {
adapter: adapter.get_info().name,
software: adapter.get_info().device_type == wgpu::DeviceType::Cpu,
samples,
max_side,
state: Mutex::new(State { renderer, background, key: 0, access_version: 0 }),
device,
queue,
})
}
/// A device of its own, without a window (native only): the adapter the `SOLIDCRAFT_GPU`
/// policy picks ([`crate::select`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn headless() -> Result<Self> {
let (adapter, device, queue) = crate::open_headless()?;
// Errors are caught per picture (error scopes); anything else is logged, never a panic.
device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("GPU: {e}")));
Self::new(&adapter, device, queue)
}
/// The adapter's name.
pub fn adapter_name(&self) -> &str {
&self.adapter
}
/// What a picture's reply names as its renderer: `gpu`, or `gpu-software` on a software
/// adapter.
pub fn renderer(&self) -> &'static str {
if self.software { "gpu-software" } else { "gpu" }
}
/// Samples per pixel the pictures are drawn with.
pub fn samples(&self) -> u32 {
self.samples
}
/// `scene` seen by `cam`, `width` × `height` pixels: straight RGBA, rows top to bottom.
pub fn rgba(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Vec<u8>> {
if !(1..=self.max_side).contains(&width) || !(1..=self.max_side).contains(&height) {
return Err(GpuError::Render(format!("a picture is 1 to {} pixels a side, not {width} × {height}", self.max_side)));
}
let mut state = self.state.lock().map_err(|_| GpuError::Render("an earlier picture failed on this device".into()))?;
let state = &mut *state;
state.key = state.key.wrapping_add(1);
let mode = scene.analysis.map(|a| a.mode);
let access = match mode {
Some(AnalysisMode::Access { dir }) => {
state.access_version = state.access_version.wrapping_add(1);
AccessMap::build(&scene.meshes.iter().map(|m| m.mesh.as_ref()).collect::<Vec<_>>(), dir, state.access_version)
}
_ => None,
};
let frame = frame(scene, cam, [width, height], state.key, access);
let (device, queue) = (&self.device, &self.queue);
let scopes = error_scopes(device);
let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size,
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let resolved = texture("sc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC);
let multisampled = (self.samples > 1).then(|| texture("sc_capture_ms", FORMAT, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT));
let depth = texture("sc_capture_depth", DEPTH, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT);
let (resolved_view, depth_view) = (resolved.create_view(&Default::default()), depth.create_view(&Default::default()));
let multisampled_view = multisampled.as_ref().map(|t| t.create_view(&Default::default()));
let (top, bottom) = scene.background.unwrap_or(BACKGROUND);
let background = background_vertices(device, top, bottom);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("sc_capture") });
state.renderer.prepare(device, queue, &mut encoder, &frame);
{
let (view, resolve_target, store) = match &multisampled_view {
Some(ms) => (ms, Some(&resolved_view), wgpu::StoreOp::Discard),
None => (&resolved_view, None, wgpu::StoreOp::Store),
};
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_capture"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store },
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }),
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }),
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
})
.forget_lifetime();
// The background first, as the viewport paints it before the model.
pass.set_pipeline(&state.background);
pass.set_vertex_buffer(0, background.slice(..));
pass.draw(0..6, 0..1);
state.renderer.paint(&frame, &mut pass, [0.0, 0.0, width as f32, height as f32]);
}
let row = width * 4;
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_capture_read"),
size: u64::from(padded) * u64::from(height),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
resolved.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(height) },
},
size,
);
let submission = queue.submit([encoder.finish()]);
if let Some(e) = caught(scopes) {
return Err(GpuError::Render(e));
}
let slice = read.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
device
.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) })
.map_err(|e| GpuError::Render(format!("waiting for the picture: {e}")))?;
match rx.try_recv() {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(GpuError::Render(format!("reading the picture back: {e}"))),
Err(_) => return Err(GpuError::Unsupported("reading a picture back without waiting for the browser".into())),
}
let data = slice.get_mapped_range().map_err(|e| GpuError::Render(format!("reading the picture back: {e}")))?;
let mut rgba = Vec::with_capacity((row as usize) * (height as usize));
for r in data.chunks(padded as usize).take(height as usize) {
rgba.extend_from_slice(r.get(..row as usize).unwrap_or_default());
}
if rgba.len() != (row as usize) * (height as usize) {
return Err(GpuError::Render("the picture read back short".into()));
}
Ok(rgba)
}
/// [`Capturer::rgba`] as PNG bytes.
pub fn png(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Vec<u8>> {
let rgba = self.rgba(scene, cam, width, height)?;
solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| GpuError::Render("PNG encoding failed".into()))
}
}
/// The process's headless capturer (native only), opened on first use under the `SOLIDCRAFT_GPU`
/// policy and kept; the error when no adapter qualifies or the device fails (not retried).
#[cfg(not(target_arch = "wasm32"))]
pub fn shared() -> std::result::Result<&'static Capturer, &'static GpuError> {
static SHARED: std::sync::OnceLock<Result<Capturer>> = std::sync::OnceLock::new();
SHARED.get_or_init(Capturer::headless).as_ref()
}
/// What draws this process's headless pictures (native only): the shared headless device, or the
/// CPU rasterizer where the `SOLIDCRAFT_GPU` policy lands there (`cpu`, or `auto` with no
/// qualifying adapter).
#[cfg(not(target_arch = "wasm32"))]
pub enum Headless {
Gpu(&'static Capturer),
Cpu,
}
/// [`Headless`] under the `SOLIDCRAFT_GPU` policy; an error when the policy is `hardware` or
/// `software` and no adapter of that tier opens (or the variable names no policy).
#[cfg(not(target_arch = "wasm32"))]
pub fn headless() -> std::result::Result<Headless, String> {
use crate::select::Policy;
let policy = Policy::from_env()?;
if policy == Policy::Cpu {
return Ok(Headless::Cpu);
}
match shared() {
Ok(c) => Ok(Headless::Gpu(c)),
Err(e) if policy == Policy::Auto => {
log::warn!("pictures are drawn by the CPU rasterizer: {e}");
Ok(Headless::Cpu)
}
Err(e) => Err(e.to_string()),
}
}
/// `scene` drawn by [`headless`]: straight RGBA and the renderer that drew it (`gpu`,
/// `gpu-software` or `cpu`). The CPU rasterizer draws no surface analysis, highlights or legends.
#[cfg(not(target_arch = "wasm32"))]
pub fn headless_rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> std::result::Result<(Vec<u8>, &'static str), String> {
match headless()? {
Headless::Gpu(c) => c.rgba(scene, cam, width, height).map(|rgba| (rgba, c.renderer())).map_err(|e| e.to_string()),
Headless::Cpu => {
if !(1..=MAX_SIDE).contains(&width) || !(1..=MAX_SIDE).contains(&height) {
return Err(format!("a picture is 1 to {MAX_SIDE} pixels a side, not {width} × {height}"));
}
let canvas = solidcraft_render::render(scene, cam, width as usize, height as usize);
Ok((canvas.rgba, "cpu"))
}
}
}
/// The frame parameters of a picture: the scene's own geometry as the model scene (key `key`),
/// the camera, the section, the analysis, and the silhouette outlines of the default visual style.
fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, access: Option<AccessMap>) -> Frame {
let empty = || Arc::new(Mutex::new(None));
let eye = cam.eye().to_f32();
let cap = scene.cap.unwrap_or(SECTION_CAP);
let mode = scene.analysis.map(|a| a.mode);
Frame {
product: false,
key,
slot: Arc::new(Mutex::new(Some(scene_geometry(scene)))),
sketch_key: 0,
sketch_slot: empty(),
hl_key: 0,
hl_slot: empty(),
pv_key: 0,
pv_slot: empty(),
view_proj: cam.view_proj(f64::from(width) / f64::from(height.max(1)), scene.radius.max(1.0)).to_f32(),
back: cam.back().to_f32(),
size_px: [width as f32, height as f32],
clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]),
cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, 1.0],
images: Vec::new(),
analysis: analysis_uniform(mode, access.as_ref()),
analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)),
access,
outline: true,
eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }],
ssaa: 1.0,
}
}
/// A scene's meshes and lines as the viewport's model scene: faces in their colours (smooth
/// normals; a face normal where a vertex has none), lines as wide lines (over everything when
/// `on_top`), the grid under everything.
pub fn scene_geometry(scene: &Scene) -> GpuScene {
let mut sc = GpuScene::default();
let c4 = |c: Rgb| [c.0, c.1, c.2, 255];
for m in &scene.meshes {
let faces: HashMap<u32, Rgb> = m.face_colors.iter().copied().collect();
let mesh = &m.mesh;
for (ti, t) in mesh.triangles.iter().enumerate() {
let col = c4(mesh.tri_face.get(ti).and_then(|f| faces.get(f)).copied().unwrap_or(m.color));
let [Some(a), Some(b), Some(c)] = t.map(|k| mesh.positions.get(k as usize).copied()) else { continue };
let flat = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
for (k, p) in t.iter().zip([a, b, c]) {
let n = mesh.normals.get(*k as usize).copied().filter(|n| n.is_finite() && n.len() > 0.5).unwrap_or(flat);
sc.tri(p.to_f32(), n.to_f32(), col);
}
}
}
for l in &scene.grid {
for w in l.points.windows(2) {
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32);
}
}
for l in &scene.lines {
for w in l.points.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32, l.on_top);
}
}
sc
}
/// Error scopes catching what a picture's GPU work may raise (popped by [`caught`]).
fn error_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 3] {
[wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f))
}
/// The first error the scopes caught.
fn caught(scopes: [wgpu::ErrorScopeGuard; 3]) -> Option<String> {
let mut failed = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = popped(scope.pop()) {
failed.get_or_insert(e.to_string());
}
}
failed
}
/// The background gradient: the viewport's top colour at the top, its bottom one at the bottom,
/// mixed in sRGB as the window paints it.
const BACKGROUND_SHADER: &str = r#"
struct O { @builtin(position) pos: vec4<f32>, @location(0) color: vec4<f32> };
@vertex
fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>) -> O {
var o: O;
o.pos = vec4<f32>(p, 0.0, 1.0);
o.color = c;
return o;
}
@fragment
fn fs(i: O) -> @location(0) vec4<f32> {
return i.color;
}
"#;
/// Background vertex: NDC position (2 × f32) and sRGB colour (4 × u8).
const BACKGROUND_VERTEX: u64 = 12;
fn background_vertices(device: &wgpu::Device, top: Rgb, bottom: Rgb) -> wgpu::Buffer {
let mut bytes = Vec::with_capacity(6 * BACKGROUND_VERTEX as usize);
let corner = |x: f32, y: f32| (x, y, if y > 0.0 { top } else { bottom });
for (x, y, c) in [corner(-1.0, 1.0), corner(1.0, 1.0), corner(1.0, -1.0), corner(-1.0, 1.0), corner(1.0, -1.0), corner(-1.0, -1.0)] {
bytes.extend_from_slice(&x.to_le_bytes());
bytes.extend_from_slice(&y.to_le_bytes());
bytes.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("sc_capture_background"),
contents: &bytes,
usage: wgpu::BufferUsages::VERTEX,
})
}
fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sc_capture_background"),
source: wgpu::ShaderSource::Wgsl(BACKGROUND_SHADER.into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_capture_background"),
bind_group_layouts: &[],
immediate_size: 0,
});
const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_capture_background"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: BACKGROUND_VERTEX, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A })],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: wgpu::MultisampleState { count: samples, mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
}
+24 -14
View File
@@ -1,7 +1,9 @@
//! GPU evaluation of display tessellation plans: the surface point and normal at every
//! vertex's parameters, in a compute shader (`tess.wgsl`), the GPU twin of
//! [`TessPlan::evaluate`]. Shared edge nodes keep their exact positions, so faces still meet
//! exactly; evaluation runs in f32.
//! SolidCraft's GPU work: the viewport's render pipelines ([`view`]), pictures drawn with them
//! offscreen ([`capture`]), and display tessellation plans evaluated in a compute shader.
//!
//! Plan evaluation: the surface point and normal at every vertex's parameters, in a compute
//! shader (`tess.wgsl`), the GPU twin of [`TessPlan::evaluate`]. Shared edge nodes keep their
//! exact positions, so faces still meet exactly; evaluation runs in f32.
//!
//! Use [`TessGpu::new`] with the application's device, or [`TessGpu::headless`] (native only)
//! to open one without a window, as the CLI and tests do. A viewport draws a plan straight from
@@ -17,6 +19,12 @@ use solidcraft_geom::Vec3;
use solidcraft_geom::tess::{EvalCurve, EvalSurface, TessPlan};
use wgpu::util::DeviceExt;
pub mod capture;
mod pbr;
#[cfg(not(target_arch = "wasm32"))]
pub mod select;
pub mod view;
/// The tessellation compute shader (WGSL).
pub const SHADER: &str = include_str!("tess.wgsl");
@@ -30,17 +38,15 @@ fn popped(f: impl std::future::Future<Output = Option<wgpu::Error>>) -> Option<w
}
}
/// An adapter and device without a window (native only). The environment may pick the backend
/// and the D3D12 shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
/// An adapter and device without a window (native only): the adapter [`select::pick`] chooses
/// under the `SOLIDCRAFT_GPU` policy. The environment may narrow the backends and pick the D3D12
/// shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
#[cfg(not(target_arch = "wasm32"))]
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.map_err(|_| GpuError::NoAdapter)?;
let policy = select::Policy::from_env().map_err(GpuError::Policy)?;
let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("solidcraft compute"),
label: Some("solidcraft headless"),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
@@ -54,12 +60,16 @@ const WORKGROUP: u32 = 64;
#[derive(Debug, thiserror::Error)]
pub enum GpuError {
#[error("no GPU adapter is available")]
NoAdapter,
#[error("no GPU adapter qualifies under the `{0}` policy (SOLIDCRAFT_GPU); `solidcraft-cli gpu` lists the adapters this machine offers")]
NoAdapter(&'static str),
#[error("{0}")]
Policy(String),
#[error("the GPU device could not be opened: {0}")]
Device(String),
#[error("the GPU evaluation failed: {0}")]
Failed(String),
#[error("the GPU render failed: {0}")]
Render(String),
#[error("the GPU tessellation pipeline could not be created: {0}")]
Pipeline(String),
#[error("not supported yet: {0}")]
File renamed without changes.
+261
View File
@@ -0,0 +1,261 @@
//! What draws a headless picture: every adapter the platform's native backends offer is
//! classified as hardware (a discrete, integrated or virtual GPU) or software (a GPU driver that
//! runs on the CPU, such as llvmpipe, lavapipe or WARP), and a [`Policy`] picks one. The last
//! tier, below any adapter, is SolidCraft's own CPU rasterizer (no surface analysis, highlights
//! or legends): `auto` lands there only when no adapter qualifies, `cpu` forces it, `hardware`
//! and `software` never use it. macOS uses Metal only, and has no software adapter. `SOLIDCRAFT_GPU`
//! (`auto`, `hardware`, `software`, `cpu`) sets the policy from the environment; `WGPU_BACKEND`
//! still narrows the backends.
use serde::Serialize;
/// Whether an adapter is a GPU or a software rasterizer.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Hardware,
Software,
}
/// Which adapters a headless device may open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Policy {
/// The best hardware adapter, else a software one.
#[default]
Auto,
/// A hardware adapter or nothing.
Hardware,
/// A software adapter or nothing.
Software,
/// The CPU rasterizer, whatever adapters there are.
Cpu,
}
/// Where a policy lands: an adapter of a tier, or the CPU rasterizer.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Landing {
Hardware,
Software,
Cpu,
}
impl Landing {
/// What a picture's reply names as its renderer: `gpu`, `gpu-software` or `cpu`.
pub fn renderer(self) -> &'static str {
match self {
Landing::Hardware => "gpu",
Landing::Software => "gpu-software",
Landing::Cpu => "cpu",
}
}
}
impl From<Tier> for Landing {
fn from(t: Tier) -> Self {
match t {
Tier::Hardware => Landing::Hardware,
Tier::Software => Landing::Software,
}
}
}
impl Policy {
/// `auto`, `hardware`, `software` or `cpu` (case-insensitive).
pub fn parse(s: &str) -> Option<Policy> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Policy::Auto),
"hardware" | "hw" | "gpu" => Some(Policy::Hardware),
"software" | "sw" => Some(Policy::Software),
"cpu" => Some(Policy::Cpu),
_ => None,
}
}
/// The policy `SOLIDCRAFT_GPU` names; [`Policy::Auto`] when it is unset, and an error when
/// it names none.
pub fn from_env() -> Result<Policy, String> {
match std::env::var("SOLIDCRAFT_GPU") {
Ok(v) => Policy::parse(&v).ok_or_else(|| format!("SOLIDCRAFT_GPU must be auto, hardware, software or cpu, not `{v}`")),
Err(_) => Ok(Policy::Auto),
}
}
/// `auto`, `hardware`, `software` or `cpu`.
pub fn name(self) -> &'static str {
match self {
Policy::Auto => "auto",
Policy::Hardware => "hardware",
Policy::Software => "software",
Policy::Cpu => "cpu",
}
}
fn admits(self, tier: Tier) -> bool {
match self {
Policy::Auto => true,
Policy::Hardware => tier == Tier::Hardware,
Policy::Software => tier == Tier::Software,
Policy::Cpu => false,
}
}
/// Where the policy lands when the best qualifying adapter is of tier `adapter` (`None`: no
/// adapter qualifies): that adapter; else the CPU rasterizer under `auto` (and always under
/// `cpu`); else nowhere, and a picture is an error.
pub fn lands_on(self, adapter: Option<Tier>) -> Option<Landing> {
match (self, adapter) {
(Policy::Cpu, _) => Some(Landing::Cpu),
(_, Some(t)) => Some(t.into()),
(Policy::Auto, None) => Some(Landing::Cpu),
(Policy::Hardware | Policy::Software, None) => None,
}
}
}
/// One adapter as the survey saw it.
#[derive(Clone, Debug, Serialize)]
pub struct AdapterReport {
pub name: String,
pub backend: String,
pub device_type: String,
pub driver: String,
pub tier: Tier,
}
/// The backends a headless device looks at on this platform: Metal on macOS, Vulkan and D3D12
/// on Windows, Vulkan and GL elsewhere; `WGPU_BACKEND` narrows them further.
pub fn native_backends() -> wgpu::Backends {
let native = if cfg!(target_os = "macos") {
wgpu::Backends::METAL
} else if cfg!(target_os = "windows") {
wgpu::Backends::DX12 | wgpu::Backends::VULKAN
} else {
wgpu::Backends::VULKAN | wgpu::Backends::GL
};
match wgpu::Backends::from_env() {
Some(env) if !(env & native).is_empty() => env & native,
_ => native,
}
}
fn tier_of(t: wgpu::DeviceType) -> Tier {
match t {
wgpu::DeviceType::Cpu => Tier::Software,
wgpu::DeviceType::DiscreteGpu | wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::VirtualGpu | wgpu::DeviceType::Other => Tier::Hardware,
}
}
/// Preference among adapters (higher first): a discrete GPU, an integrated one, a virtual one,
/// an unknown kind, a software rasterizer.
fn rank(t: wgpu::DeviceType) -> u8 {
match t {
wgpu::DeviceType::DiscreteGpu => 4,
wgpu::DeviceType::IntegratedGpu => 3,
wgpu::DeviceType::VirtualGpu => 2,
wgpu::DeviceType::Other => 1,
wgpu::DeviceType::Cpu => 0,
}
}
fn report(info: &wgpu::AdapterInfo) -> AdapterReport {
AdapterReport {
name: info.name.clone(),
backend: format!("{:?}", info.backend),
device_type: format!("{:?}", info.device_type),
driver: [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" "),
tier: tier_of(info.device_type),
}
}
fn instance() -> wgpu::Instance {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = native_backends();
wgpu::Instance::new(desc)
}
/// Every adapter the native backends offer, best first.
#[cfg(not(target_arch = "wasm32"))]
fn adapters(instance: &wgpu::Instance) -> Vec<wgpu::Adapter> {
let mut all = pollster::block_on(instance.enumerate_adapters(native_backends()));
all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type)));
all
}
/// What a survey found, which adapter the policy picks (`chosen` indexes `adapters`) and where
/// it lands (`None`: nowhere, a picture is an error).
#[derive(Clone, Debug, Serialize)]
pub struct Survey {
pub backends: String,
pub adapters: Vec<AdapterReport>,
pub chosen: Option<usize>,
pub lands_on: Option<Landing>,
}
/// Every adapter the native backends offer, best first, and which one `policy` picks.
#[cfg(not(target_arch = "wasm32"))]
pub fn survey(policy: Policy) -> Survey {
let found: Vec<AdapterReport> = adapters(&instance()).iter().map(|a| report(&a.get_info())).collect();
let chosen = found.iter().position(|r| policy.admits(r.tier));
let lands_on = policy.lands_on(chosen.and_then(|i| found.get(i)).map(|r| r.tier));
Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on }
}
/// The adapter `policy` picks, with its report; `None` when no adapter qualifies.
#[cfg(not(target_arch = "wasm32"))]
pub fn pick(policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> {
adapters(&instance())
.into_iter()
.map(|a| {
let r = report(&a.get_info());
(a, r)
})
.find(|(_, r)| policy.admits(r.tier))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policies_parse_and_admit_their_tiers() {
assert_eq!(Policy::parse(" Hardware "), Some(Policy::Hardware));
assert_eq!(Policy::parse("sw"), Some(Policy::Software));
assert_eq!(Policy::parse("metal"), None);
assert_eq!(Policy::parse("CPU"), Some(Policy::Cpu));
assert!(!Policy::Cpu.admits(Tier::Software) && !Policy::Cpu.admits(Tier::Hardware));
assert!(Policy::Auto.admits(Tier::Software) && Policy::Auto.admits(Tier::Hardware));
assert!(!Policy::Hardware.admits(Tier::Software));
assert!(!Policy::Software.admits(Tier::Hardware));
}
/// Hardware GPU, then a software adapter, then the CPU rasterizer: `auto` walks all three,
/// `hardware` and `software` never fall to the CPU, and `cpu` always lands there.
#[test]
fn policies_land_on_their_tiers() {
use Landing as L;
let (hw, sw) = (Some(Tier::Hardware), Some(Tier::Software));
assert_eq!([hw, sw, None].map(|t| Policy::Auto.lands_on(t)), [Some(L::Hardware), Some(L::Software), Some(L::Cpu)]);
assert_eq!(Policy::Hardware.lands_on(hw), Some(L::Hardware));
assert_eq!(Policy::Hardware.lands_on(None), None);
assert_eq!(Policy::Software.lands_on(sw), Some(L::Software));
assert_eq!(Policy::Software.lands_on(None), None);
assert_eq!([hw, sw, None].map(|t| Policy::Cpu.lands_on(t)), [Some(L::Cpu); 3]);
assert_eq!([L::Hardware, L::Software, L::Cpu].map(L::renderer), ["gpu", "gpu-software", "cpu"]);
}
#[test]
fn software_rasterizers_rank_last() {
assert_eq!(tier_of(wgpu::DeviceType::Cpu), Tier::Software);
assert!(rank(wgpu::DeviceType::IntegratedGpu) > rank(wgpu::DeviceType::Cpu));
assert!(rank(wgpu::DeviceType::DiscreteGpu) > rank(wgpu::DeviceType::IntegratedGpu));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_surveys_metal_only() {
assert_eq!(native_backends() & !wgpu::Backends::METAL, wgpu::Backends::empty());
let s = survey(Policy::Auto);
assert!(s.adapters.iter().all(|a| a.backend == "Metal"), "{s:?}");
}
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
//! 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<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
}
/// 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());
}
+4
View File
@@ -11,3 +11,7 @@ workspace = true
[dependencies]
solidcraft-engine = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
# The renderer `solidcraft-cli mcp` installs, so the tests' screenshots are drawn as the server's.
solidcraft-gpu = { workspace = true }
+17 -7
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::render::StandardView;
use solidcraft_engine::view::Guides;
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
@@ -234,8 +234,8 @@ impl Headless {
}
}
fn dim(p: &Value, k: &str, default: f64) -> usize {
p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as usize
fn dim(p: &Value, k: &str, default: f64) -> u32 {
p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as u32
}
impl Backend for Headless {
@@ -279,11 +279,21 @@ impl Backend for Headless {
// The grid and the axes are drawn unless the request leaves them out.
let shown = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
let guides = Guides { grid: shown("grid"), axes: shown("axes") };
let scene = solidcraft_engine::view::scene_with_guides(s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
let path = str_p("path").ok_or("missing `path`")?;
solidcraft_engine::io::vfs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
Ok(json!({"path": path, "bytes": png.len(), "width": w, "height": h}))
// A path outside the agent's folders is refused before anything is drawn.
solidcraft_engine::io::vfs::check(path).map_err(|e| format!("{path}: {e}"))?;
let scene = solidcraft_engine::view::scene_with_guides(s, &cam, s.section, guides);
// Drawn on the GPU by the renderer the host installed (`solidcraft-cli mcp`).
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
solidcraft_engine::io::vfs::write(path, &png.bytes).map_err(|e| format!("{path}: {e}"))?;
Ok(json!({
"path": path,
"bytes": png.bytes.len(),
"width": w,
"height": h,
"renderer": png.renderer,
"warning": solidcraft_engine::capture::warning(png.renderer),
}))
}
"ui.screenshot" => Err("no window in headless mode; use ui.render".into()),
other => Err(format!("unknown method `{other}`")),
+44 -9
View File
@@ -10,6 +10,25 @@ fn server() -> Server {
Server::new(Box::new(Headless::default()))
}
/// Screenshots are drawn on the GPU by the renderer the host installs (`solidcraft-cli mcp`); the
/// tests install the same one. `false`, with the reason printed, on a machine without a GPU
/// adapter: the screenshots are then left out of the test.
fn renders() -> bool {
match solidcraft_gpu::capture::shared() {
Ok(c) => {
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
}));
true
}
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("screenshots left out: {e}");
false
}
Err(e) => panic!("{e}"),
}
}
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply");
@@ -166,10 +185,12 @@ fn agent_builds_box_fillet_hole() {
assert!(rel(volume(&mut s), expect) < 5e-4);
// Look at it from the top: a real PNG comes back as an image block.
let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200}));
assert_eq!(img["type"], "image");
assert_eq!(img["mimeType"], "image/png");
assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG");
if renders() {
let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200}));
assert_eq!(img["type"], "image");
assert_eq!(img["mimeType"], "image/png");
assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG");
}
// Export, save, start over, open.
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-test-{}", std::process::id()));
@@ -181,8 +202,10 @@ fn agent_builds_box_fillet_hole() {
tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"}));
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
let shot = dir.join("shot.png");
tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64}));
assert!(std::fs::metadata(&shot).unwrap().len() > 100);
if renders() {
tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64}));
assert!(std::fs::metadata(&shot).unwrap().len() > 100);
}
let design = dir.join("part.solidcraft");
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
tool(&mut s, "new_design", json!({"name": "Empty"}));
@@ -524,7 +547,9 @@ fn the_default_folders_are_the_designs_the_recovery_folder_and_the_scratch_folde
tool(&mut s, "execute", json!({"command": "parameters.export", "params": {"path": at(&proj, "params.csv")}}));
tool(&mut s, "save", json!({"path": at(&proj, "copy.solidcraft")}));
tool(&mut s, "open", json!({"path": at(&proj, "part.solidcraft")}));
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
if renders() {
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
}
tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {"dir": recovery.to_string_lossy()}}));
assert!(proj.join("part.step").exists() && proj.join("copy.solidcraft").exists());
// Hidden files in it, and folders beside it, stay out of reach.
@@ -563,7 +588,9 @@ fn tool_calls_stay_inside_the_agents_folders() {
tool(&mut s, "save", json!({"path": at(&root, "part.solidcraft")}));
tool(&mut s, "export", json!({"path": at(&root, "part.step")}));
tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")}));
tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64}));
if renders() {
tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64}));
}
let escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")];
#[cfg(unix)]
let mut escapes = escapes;
@@ -644,7 +671,9 @@ fn short_names_child() {
assert!(windows_refusal(&temp.to_string_lossy()).is_some_and(|why| why.contains("8.3")), "{}", temp.display());
let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(crate::agent_scope([])));
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
if renders() {
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
}
tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {}}));
// An agent's own short spelling of the scratch folder stays refused.
let scratch = crate::scratch_dir().unwrap();
@@ -687,6 +716,9 @@ fn a_bridged_request_carries_the_agents_folders() {
/// standard view.
#[test]
fn screenshot_renders_a_saved_named_view() {
if !renders() {
return;
}
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10}}));
let camera = json!({"target": [20, 10, 5], "yaw": 0.5, "pitch": -0.6, "distance": 150});
@@ -710,6 +742,9 @@ fn screenshot_renders_a_saved_named_view() {
/// `browser.visibility` stays out whatever `axes` says.
#[test]
fn screenshot_can_leave_the_grid_and_the_axes_out() {
if !renders() {
return;
}
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}}));
let shot = |s: &mut Server, extra: Value| {
+8 -1
View File
@@ -658,7 +658,14 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
_ => (info.get("width").cloned().unwrap_or(Value::Null), info.get("height").cloned().unwrap_or(Value::Null)),
};
let bytes = png.len();
Ok((png, json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes})))
let mut reply = json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes});
// What drew a model render (gpu, gpu-software or cpu), and the warning a CPU render carries.
for k in ["renderer", "warning"] {
if let Some(v) = info.get(k).filter(|v| !v.is_null()) {
reply[k] = v.clone();
}
}
Ok((png, reply))
}
/// Standard base64 with padding (RFC 4648).
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-render"
description = "SolidCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots"
description = "SolidCraft view math (orbit camera, projection, picking rays), the description of headless pictures, and product render jobs"
version.workspace = true
edition.workspace = true
license.workspace = true
+54
View File
@@ -0,0 +1,54 @@
//! Surface analysis as a view draws it: the shading mode and the colours it uses.
use solidcraft_geom::Vec3;
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
/// A way of shading the model's faces to judge their shape.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AnalysisMode {
/// Reflected stripes: `stripes` across the view.
Zebra { stripes: f64 },
/// Faces by draft angle (degrees) to `pull`.
Draft { pull: Vec3, angle: f64 },
/// Colour by curvature, reaching the tight end at 1 / `radius`.
Curvature { radius: f64 },
/// Mirror finish reflecting a studio.
Environment,
/// Reachable along `dir` (facing it, nothing above) or blocked.
Access { dir: Vec3 },
}
/// A surface analysis shading a picture: the mode and its colours.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SceneAnalysis {
pub mode: AnalysisMode,
pub colors: AnalysisColors,
}
+5 -1
View File
@@ -4,6 +4,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod analysis;
mod backend;
mod camera;
mod curve_display;
@@ -11,6 +12,7 @@ pub mod job;
pub mod lighting;
mod product;
mod raster;
mod scene;
pub mod studio;
pub mod surface;
pub mod texture_input;
@@ -18,6 +20,7 @@ pub mod texture_interpolation;
pub mod texture_lod;
pub mod texture_sampling;
pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis};
pub use backend::{
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend,
@@ -26,4 +29,5 @@ pub use backend::{
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use product::{FaceAppearance, ProductMesh, ProductScene};
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
pub use raster::{Canvas, render, render_png, render_transparent};
pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png};
+7 -55
View File
@@ -1,54 +1,10 @@
//! CPU rasterizer for headless snapshots: z-buffered, smooth-shaded triangles and depth-tested
//! lines, rendered at 2× and downsampled for anti-aliasing.
use std::sync::Arc;
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_geom::Vec3;
use crate::Camera;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
fn f(self) -> [f64; 3] {
[self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0]
}
}
pub struct SceneMesh {
pub mesh: Arc<Mesh>,
pub color: Rgb,
/// Faces in colours of their own: (B-rep face index, colour).
pub face_colors: Vec<(u32, Rgb)>,
}
pub struct SceneLine {
pub points: Vec<Vec3>,
pub color: Rgb,
/// Width in output pixels.
pub width: f64,
/// Draw over everything (sketch geometry being edited).
pub on_top: bool,
}
#[derive(Default)]
pub struct Scene {
pub meshes: Vec<SceneMesh>,
pub lines: Vec<SceneLine>,
/// Background gradient top → bottom.
pub background: Option<(Rgb, Rgb)>,
/// Scene radius for clipping planes.
pub radius: f64,
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
/// shows as a hatched cap.
pub clip: Option<(Vec3, f64)>,
/// The colour the section cap is tinted toward (default: [`SECTION_CAP`]).
pub cap: Option<Rgb>,
}
/// The section cap's default tint, as in the viewport.
pub const SECTION_CAP: Rgb = Rgb(214, 128, 72);
use crate::scene::{Rgb, SECTION_CAP, Scene, png};
/// RGBA pixels plus depth.
pub struct Canvas {
@@ -220,7 +176,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
*s = s.max(cap_slope);
}
}
for l in &scene.lines {
for l in scene.grid.iter().chain(&scene.lines) {
let col = l.color.f();
let half = (l.width * SS as f64 * 0.5).max(0.5);
for seg in l.points.windows(2) {
@@ -326,14 +282,6 @@ pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec
png(c.w, c.h, c.rgba)
}
/// RGBA pixels (`w × h`) to PNG bytes.
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(buf.into_inner())
}
/// Render on a transparent background: the scene drawn over black and over white, and the
/// alpha recovered from the difference (exact for the anti-aliased edges).
pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
@@ -359,6 +307,9 @@ pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -
mod tests {
use super::*;
use crate::StandardView;
use crate::scene::{SceneLine, SceneMesh};
use solidcraft_geom::Mesh;
use std::sync::Arc;
/// Lines are cut by the section in 3D: the kept piece ends on the plane.
#[test]
@@ -391,6 +342,7 @@ mod tests {
radius: 20.0,
clip: None,
cap: None,
..Default::default()
};
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
+65
View File
@@ -0,0 +1,65 @@
//! What a picture of the model shows, independent of how it is drawn: meshes in colours, lines,
//! the background, a section cut and a surface analysis. The GPU draws it with the viewport's
//! pipelines (`solidcraft_gpu::capture`).
use std::sync::Arc;
use solidcraft_geom::{Mesh, Vec3};
use crate::SceneAnalysis;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
/// The colour as 0..1 per channel.
pub fn f(self) -> [f64; 3] {
[self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0]
}
}
pub struct SceneMesh {
pub mesh: Arc<Mesh>,
pub color: Rgb,
/// Faces in colours of their own: (B-rep face index, colour).
pub face_colors: Vec<(u32, Rgb)>,
}
pub struct SceneLine {
pub points: Vec<Vec3>,
pub color: Rgb,
/// Width in output pixels.
pub width: f64,
/// Draw over everything (sketch geometry being edited).
pub on_top: bool,
}
#[derive(Default)]
pub struct Scene {
pub meshes: Vec<SceneMesh>,
pub lines: Vec<SceneLine>,
/// The ground grid: drawn under everything (the model hides it; it never hides the model).
pub grid: Vec<SceneLine>,
/// Background gradient top → bottom.
pub background: Option<(Rgb, Rgb)>,
/// Scene radius for clipping planes.
pub radius: f64,
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
/// shows as a hatched cap.
pub clip: Option<(Vec3, f64)>,
/// The colour the section cap is tinted toward (default: [`SECTION_CAP`]).
pub cap: Option<Rgb>,
/// A surface analysis shading the meshes (zebra, draft, curvature map, ...).
pub analysis: Option<SceneAnalysis>,
}
/// The section cap's default tint, as in the viewport.
pub const SECTION_CAP: Rgb = Rgb(214, 128, 72);
/// RGBA pixels (`w × h`) to PNG bytes.
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(buf.into_inner())
}
+1 -73
View File
@@ -2,17 +2,12 @@
//! to tell reachable surface from surface under something else.
use std::cell::RefCell;
use std::sync::Arc;
use solidcraft_engine::SurfaceAnalysis;
use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::gpu::AccessMap;
/// Map resolution (pixels per side).
const SIZE: u32 = 1024;
thread_local! {
/// The last map, by (model revision, direction).
static CACHE: RefCell<Option<((u64, [u64; 3]), AccessMap)>> = const { RefCell::new(None) };
@@ -33,74 +28,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
v.set(v.get() + 1);
v.get()
});
let m = build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), dir, version)?;
let m = AccessMap::build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), dir, version)?;
CACHE.with(|c| *c.borrow_mut() = Some((key, m.clone())));
Some(m)
}
/// Rasterise the triangles seen along `dir` (orthographic), keeping the highest point per pixel.
fn build(meshes: &[&solidcraft_engine::geom::Mesh], dir: Vec3, version: u64) -> Option<AccessMap> {
let d = dir.normalized()?;
let u = d.cross(if d.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?;
let v = d.cross(u);
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for m in meshes {
for p in &m.positions {
let (a, b) = (p.dot(u), p.dot(v));
lo = [lo[0].min(a), lo[1].min(b)];
hi = [hi[0].max(a), hi[1].max(b)];
}
}
let ext = (hi[0] - lo[0]).max(hi[1] - lo[1]);
if !(ext.is_finite() && ext > 1e-9) {
return None;
}
let n = SIZE as usize;
let scale = (SIZE - 2) as f64 / ext;
// One pixel of margin on each side.
let (ou, ov) = (-lo[0] + 1.0 / scale, -lo[1] + 1.0 / scale);
let mut depth = vec![f32::NEG_INFINITY; n * n];
for m in meshes {
for t in &m.triangles {
let q: Vec<(f64, f64, f64)> =
t.iter().filter_map(|i| m.positions.get(*i as usize)).map(|p| ((p.dot(u) + ou) * scale, (p.dot(v) + ov) * scale, p.dot(d))).collect();
let [a, b, c] = q[..] else { continue };
let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
let (x0, x1) = (a.0.min(b.0).min(c.0).floor().max(0.0) as usize, (a.0.max(b.0).max(c.0).ceil() as usize).min(n - 1));
let (y0, y1) = (a.1.min(b.1).min(c.1).floor().max(0.0) as usize, (a.1.max(b.1).max(c.1).ceil() as usize).min(n - 1));
// Edge-on triangles: their vertices still count.
if area.abs() < 1e-12 {
for p in [a, b, c] {
let (x, y) = (p.0 as usize, p.1 as usize);
if let Some(z) = depth.get_mut(y * n + x) {
*z = z.max(p.2 as f32);
}
}
continue;
}
for y in y0..=y1 {
for x in x0..=x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area;
let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
let z = (w0 * a.2 + w1 * b.2 + w2 * c.2) as f32;
if let Some(cell) = depth.get_mut(y * n + x) {
*cell = cell.max(z);
}
}
}
}
}
Some(AccessMap {
version,
size: SIZE,
depth: Arc::new(depth),
u: [u.x as f32, u.y as f32, u.z as f32, ou as f32],
v: [v.x as f32, v.y as f32, v.z as f32, ov as f32],
scale: scale as f32,
})
}
+6 -3
View File
@@ -50,7 +50,7 @@ const MAX_PROMPT: usize = 100_000;
/// up on a request after five minutes).
const APPROVAL_TIMEOUT_MS: f64 = 240_000.0;
/// Size of the picture of the view sent with a message.
const VIEW_IMAGE: (usize, usize) = (960, 600);
const VIEW_IMAGE: (u32, u32) = (960, 600);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
@@ -521,8 +521,11 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
let mut parts = vec![PromptPart::Text(text.clone())];
if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) });
if image {
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
Err(e) => log::warn!("the view's picture is left out of the prompt: {e}"),
}
}
let label: String = text.lines().next().unwrap_or("").chars().take(48).collect();
app.agent_chat.turn =
+57 -32
View File
@@ -43,7 +43,7 @@ use std::sync::mpsc::Sender;
use serde_json::{Value, json};
use solidcraft_engine::io::vfs;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::render::StandardView;
use crate::SolveApp;
@@ -172,19 +172,35 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathS
Ok(Some(vfs::PathScope::new(roots).with_design(app.session.path.as_deref())))
}
/// A model render of the view as the window shows it (theme, section, grid and axes), as PNG.
pub fn view_png(app: &SolveApp, w: usize, h: usize) -> Option<Vec<u8>> {
let guides =
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() };
let scene = solidcraft_engine::view::scene_with_palette_and_guides(
/// What a picture of the view shows: the model as the window shows it (theme and colour vision
/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's
/// Slice), with the grid and the origin's axes as `guides` says.
pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::Scene {
solidcraft_engine::view::scene_with_palette_and_guides(
&app.session,
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
guides,
);
render_png(&scene, &app.cam, w, h)
)
}
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides {
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
}
/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's
/// pipelines (the renderer the app installs).
pub(crate) fn view_rgba(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
solidcraft_engine::capture::rgba(&view_scene(app, window_guides(app)), &app.cam, w, h).map(|p| p.rgba)
}
/// [`view_rgba`] as PNG.
pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
let rgba = view_rgba(app, w, h)?;
solidcraft_engine::render::png(w as usize, h as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())
}
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
@@ -694,31 +710,32 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
_ => Outcome::Screenshot { path: s("path").map(str::to_string) },
},
"ui.render" => {
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
// In the window's theme and colour vision mode, and cut by the section the viewport shows (an open Section
// Analysis dialog's preview, a sketch's Slice), not only the committed one.
// The grid and the axes as the window shows them (the Grid and Origin switches), unless
// the request says: `grid` and `axes` leave them out of the picture or put them in.
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as u32;
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as u32;
// As the window shows it (`view_scene`). The grid and the axes as the window shows
// them, unless the request says: `grid` and `axes` leave them out or put them in.
let shown = window_guides(app);
let guides = solidcraft_engine::view::Guides {
grid: p.get("grid").and_then(Value::as_bool).unwrap_or(app.ui.show_grid && !crate::viewport::active_view(app).hide_grid),
axes: p.get("axes").and_then(Value::as_bool).unwrap_or_else(|| app.origin_visible()),
grid: p.get("grid").and_then(Value::as_bool).unwrap_or(shown.grid),
axes: p.get("axes").and_then(Value::as_bool).unwrap_or(shown.axes),
};
let scene = solidcraft_engine::view::scene_with_palette_and_guides(
&app.session,
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
guides,
);
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
// A path outside the request's folders is refused before anything is drawn.
if let Some(Err(e)) = s("path").map(vfs::check) {
return err(e);
}
let scene = view_scene(app, guides);
let png = match solidcraft_engine::capture::png(&scene, &app.cam, w, h) {
Ok(png) => png,
Err(e) => return err(format!("render failed: {e}")),
};
// What drew it (gpu, gpu-software or cpu), and the warning a CPU render carries.
let (renderer, warning) = (png.renderer, solidcraft_engine::capture::warning(png.renderer));
match s("path") {
Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png)) {
Ok(()) => ok(json!({"path": path, "bytes": png.len()})),
Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png.bytes)) {
Ok(()) => ok(json!({"path": path, "bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
Err(e) => err(e),
},
None => ok(json!({"bytes": png.len()})),
None => ok(json!({"bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
}
}
"app.quit" => {
@@ -949,6 +966,9 @@ mod tests {
/// or dark.
#[test]
fn model_render_is_in_the_windows_theme() {
if !crate::gpu::test_picture_renderer() {
return;
}
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, Services::default());
@@ -975,6 +995,9 @@ mod tests {
/// preview too, not only a committed `inspect.section`.
#[test]
fn model_render_shows_the_section_dialog_preview() {
if !crate::gpu::test_picture_renderer() {
return;
}
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, Services::default());
@@ -1014,6 +1037,9 @@ mod tests {
/// in the request say otherwise for that render.
#[test]
fn model_render_follows_the_grid_and_origin_switches_unless_asked() {
if !crate::gpu::test_picture_renderer() {
return;
}
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]})).unwrap();
let mut app = SolveApp::new(s, Services::default());
@@ -1246,7 +1272,9 @@ mod tests {
};
let export = |p: String| json!({"command": "file.export", "params": {"path": p}});
assert_eq!(call("engine.execute", export(at(&root, "a.step")), true)["ok"], true);
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
if crate::gpu::test_picture_renderer() {
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
}
assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true);
for (method, params) in [
("engine.execute", export(at(&out, "a.step"))),
@@ -1279,7 +1307,6 @@ mod tests {
s.execute("component.activate", &json!({"component": "root"})).unwrap();
let mut h = crate::scenario::Harness::new();
h.app = SolveApp::new(s, Services::default());
h.app.viewport.no_pixels = true;
h.frames(2);
let before = h.app.cam;
// Reading during an animated view change leaves it running (no frames run between).
@@ -1341,7 +1368,6 @@ mod tests {
}
// Nothing shown: nothing to fit.
let mut empty = crate::scenario::Harness::new();
empty.app.viewport.no_pixels = true;
let before = empty.app.cam;
let r = empty.call("ui.camera", json!({"yaw": 1, "fit": true}));
assert!(r["ok"] == false && empty.app.cam == before, "{r}");
@@ -1364,7 +1390,6 @@ mod tests {
s.execute("joint.create", &json!({"type": "slider", "a": at("B", 55), "b": at("C", 55), "flip": true, "name": "Rail"})).unwrap();
let mut h = crate::scenario::Harness::new();
h.app = SolveApp::new(s, Services::default());
h.app.viewport.no_pixels = true;
let before = h.app.session.doc.clone();
let drive = |h: &crate::scenario::Harness| match h.app.dialog.as_ref().map(|d| &d.kind) {
Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Drive { joint, values, .. })) => {
@@ -330,7 +330,6 @@ fn drive_value_takes_the_keyboard() {
fn drive_slider_previews_intermediate_positions_before_release() {
let mut h = crate::scenario::Harness::new();
h.app = two_boxes();
h.app.viewport.no_pixels = true;
let (a, b) = (occ(&h.app, "A"), occ(&h.app, "B"));
h.app
.run(
@@ -554,7 +554,6 @@ fn flat_app(preset: &str) -> SolveApp {
app.start("sheet.flat_pattern");
let Some(Kind::Sheet(Sm::FlatPattern { data, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("flat dialog") };
*data = Some(Ok(flat));
app.viewport.no_pixels = true;
app.preferences.nav = preset.into();
app.preferences.zoom_to_cursor = true;
app
+21 -7
View File
@@ -554,8 +554,7 @@ fn partial_picture(world: &solidcraft_engine::doc::ModelState, stop: &dyn Fn() -
if stop() || !preview_work_fits(&scene, &camera) {
return None;
}
let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200);
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
preview_image(&scene, &camera)
}
#[cfg(not(target_arch = "wasm32"))]
@@ -601,12 +600,24 @@ fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option<egui::
if !preview_work_fits(&scene, &camera) {
return None;
}
let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200);
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
preview_image(&scene, &camera)
}
/// Bound raster work as well as file size: repeated full-screen triangles must
/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles.
/// The preview picture, drawn on the GPU by the renderer the app installs (none: no picture).
#[cfg(not(target_arch = "wasm32"))]
fn preview_image(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> Option<egui::ColorImage> {
match solidcraft_engine::capture::rgba(scene, camera, 320, 200) {
Ok(picture) => Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &picture.rgba)),
Err(e) => {
log::warn!("no preview picture: {e}");
None
}
}
}
/// Bound drawing work as well as file size: repeated full-screen triangles (a hostile file) must
/// not monopolize the file worker or a software GPU. Counts each triangle's pixel box at twice the
/// picture's size.
#[cfg(not(target_arch = "wasm32"))]
fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> bool {
let mut pixels = 0.0;
@@ -950,6 +961,9 @@ mod tests {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn provisional_picture_renders_without_adopting_cached_meshes() {
if !crate::gpu::test_picture_renderer() {
return;
}
let preview = solidcraft_engine::io::DesignPreview {
meshes: vec![solidcraft_engine::doc::MeshData {
name: "Saved triangle".into(),
@@ -965,7 +979,7 @@ mod tests {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() {
fn repeated_large_preview_triangles_are_a_cache_miss_before_drawing() {
let preview = solidcraft_engine::io::DesignPreview {
meshes: vec![solidcraft_engine::doc::MeshData {
name: "Hostile overdraw".into(),
-1
View File
@@ -18,7 +18,6 @@ impl Win {
fn new() -> Self {
let mut app = SolveApp::new(Session::default(), Default::default());
app.custom_titlebar = true;
app.viewport.no_pixels = true;
let mut w = Win { ctx: egui::Context::default(), app, time: 0.0 };
for _ in 0..3 {
w.frame(vec![]);
+82 -1472
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -69,8 +69,9 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
// Fill the card: the fit leaves room for a whole window's margins.
cam.distance *= 0.7;
let scene = solidcraft_engine::view::scene(s, &cam);
let c = solidcraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]);
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
// Drawn on the GPU by the renderer the app installs; none (a browser): no picture.
let picture = solidcraft_engine::capture::rgba(&scene, &cam, THUMB[0] as u32, THUMB[1] as u32).ok()?;
Some(egui::ColorImage::from_rgba_unmultiplied(THUMB, &picture.rgba))
}
/// File identity, not just its path: saving or replacing a design invalidates its picture.
@@ -570,6 +571,9 @@ mod tests {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() {
if !crate::gpu::test_picture_renderer() {
return;
}
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("solidcraft-thumb-{}-{nonce}.solidcraft", std::process::id()));
// Deliberately invalid design: a cached recent must not parse/evaluate it.
@@ -594,6 +598,9 @@ mod tests {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn portable_thumbnail_loads_without_a_local_picture_or_model_rebuild() {
if !crate::gpu::test_picture_renderer() {
return;
}
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
for body in &session.world_state().bodies {
@@ -630,8 +637,12 @@ mod tests {
}
/// Samples build in the background and make a picture; picking one opens its design.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sample_card_makes_a_picture_and_opens() {
if !crate::gpu::test_picture_renderer() {
return;
}
let (key, img, session) = make("sample:0");
assert_eq!(key, "sample:0");
let img = img.unwrap();
+3 -3
View File
@@ -11,7 +11,7 @@ use solidcraft_engine::render::{
ScratchLayout,
};
use crate::gpu::{GpuScene, SceneSlot, ViewportCallback};
use crate::gpu::{Frame, GpuScene, SceneSlot, ViewportCallback};
use crate::{SolveApp, theme::Tokens};
const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
@@ -518,7 +518,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
let eye = camera.eye();
let slot = app.render_workspace.slot.clone();
let callback = ViewportCallback {
let callback = ViewportCallback(Frame {
product: true,
key,
slot: slot.clone(),
@@ -540,7 +540,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
outline: false,
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 },
};
});
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
Ok(())
})();
@@ -496,7 +496,6 @@ fn nav_app(render: bool, preset: &str, around: crate::prefs::OrbitAround) -> cra
let mut s = Session::default();
s.execute("solid.box", &json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap();
let mut app = crate::SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
if render {
enter(&mut app, true).unwrap();
assert!(active(&app));
+12 -3
View File
@@ -179,8 +179,6 @@ impl Harness {
/// File pickers answer like a person would: save into the scenario's folder, open the file
/// the scenario chose.
fn install(&mut self) {
// Frames are laid out, never painted.
self.app.viewport.no_pixels = true;
let dir = self.dir.clone();
self.app.services.pick_save =
Some(Box::new(move |name, _| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned())))));
@@ -444,8 +442,19 @@ impl Harness {
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
// With SOLIDCRAFT_SCENARIO_SHOTS set: a render of the model as the camera sees it.
if let Ok(dir) = solidcraft_engine::geom::env::var("SCENARIO_SHOTS").ok_or(()) {
// Drawn on the GPU: the headless device, as the CLI draws.
#[cfg(not(target_arch = "wasm32"))]
if solidcraft_engine::capture::installed().is_none() {
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
}
let path = std::path::Path::new(&dir).join(format!("{name}.png"));
self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
let r = self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
if r["ok"] != true {
return Err(format!("shot {name}: {}", r["error"]));
}
}
return Ok(());
} else if let Some(c) = s.get("camera") {
+2 -12
View File
@@ -108,18 +108,8 @@ pub fn srgb(c: [f32; 3]) -> Color32 {
}
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read
/// the same as when they were literals in the WGSL).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
/// the same as when they were literals in the WGSL). The light theme's are the headless default.
pub use solidcraft_engine::render::AnalysisColors;
/// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its
/// caller). Every icon colour is a token so a colour vision mode can reach it.
+68 -150
View File
@@ -10,7 +10,7 @@ use solidcraft_engine::sketch::CurveKind;
use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler};
use std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback};
use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag};
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
use crate::theme::Tokens;
@@ -69,9 +69,6 @@ pub struct ViewportState {
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
hl_slot: SceneSlot,
hl_key: u64,
cpu: Option<(u64, egui::TextureHandle)>,
/// Nothing will look at the frame (the headless scenario harness): skip the software render.
pub no_pixels: bool,
pub nav: Option<NavMode>,
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
/// when the button went down, so the drag neither selects nor moves sketch geometry.
@@ -1501,20 +1498,9 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solidcraft_engine::geom::Aabb3
}
/// The section as the shader's clip plane (normal, d), zero when there is none.
/// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`).
/// The surface analysis for the shader (see `gpu::Frame::analysis`).
fn analysis_uniform(app: &SolveApp) -> [f32; 8] {
use solidcraft_engine::SurfaceAnalysis as A;
match app.session.analysis {
Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0],
Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
Some(A::Access { dir }) => {
let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0));
[5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0]
}
None => [0.0; 8],
}
solidcraft_gpu::view::analysis_uniform(app.session.analysis.map(|a| a.mode()), crate::access::map(app).as_ref())
}
/// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and
@@ -2193,7 +2179,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
update_refinement(app, ui.ctx(), &proj);
let key = scene_key(app);
let t0 = crate::now_ms();
if let Some(_g) = app.viewport.gpu {
// Drawn on the GPU; without one (the headless harness, tests) the model is not drawn.
if app.viewport.gpu.is_some() {
if key != app.viewport.key {
let sc = build_scene(app);
if let Ok(mut slot) = app.viewport.slot.lock() {
@@ -2221,7 +2208,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let ppp = ui.ctx().pixels_per_point();
let size_px = [rect.width() * ppp, rect.height() * ppp];
let ssaa = supersampling(app, ui.ctx(), proj.vp.to_f32(), size_px);
let cb = ViewportCallback {
let cb = ViewportCallback(Frame {
ssaa,
product: false,
key: app.viewport.key,
@@ -2246,10 +2233,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let e = proj.cam.eye().to_f32();
[e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]
},
};
});
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
} else {
cpu_render(app, ui.ctx(), &painter, rect, &proj);
}
analysis_legend(app, &painter, rect);
@@ -2952,44 +2937,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
}
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
if app.viewport.no_pixels {
return;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
sketch_scene_key(app, proj).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
v.to_bits().hash(&mut h);
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);
app.ui.dark.hash(&mut h);
app.preferences.color_vision.hash(&mut h);
let key = h.finish();
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
let palette = scene_palette(app.ui.dark, app.preferences.color_vision);
let scene = solidcraft_engine::view::scene_with_palette(
&app.session,
&proj.cam,
section_plane(app),
|sk, index, plane| crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts,
&palette,
);
let w = (rect.width() as usize).clamp(16, 4096);
let hh = (rect.height() as usize).clamp(16, 4096);
let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh);
let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR);
app.viewport.cpu = Some((key, tex));
}
if let Some((_, tex)) = &app.viewport.cpu {
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or
/// the light theme and a colour vision mode, from the tokens the window draws with, so the picture
/// matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
/// The colours of a headless picture of the model (`ui.render`, the agent's view image) in the
/// dark or the light theme and a colour vision mode, from the tokens the window draws with, so the
/// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
/// render is unchanged.
pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette {
let t = Tokens::themed(dark, mode);
@@ -3011,6 +2961,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli
sketch_centerline: rgb(t.sketch_centerline),
axes: t.triad.map(rgb),
cap: rgb(t.section_cap),
analysis: t.analysis,
}
}
@@ -3337,7 +3288,6 @@ mod tests {
let mut s = solidcraft_engine::Session::default();
s.run_script(&solidcraft_engine::sample::script()).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
const SIZE: u32 = 256;
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
@@ -3400,7 +3350,6 @@ mod tests {
let mut s = solidcraft_engine::Session::default();
s.run_script(&solidcraft_engine::sample::script()).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
const SIZE: u32 = 256;
@@ -3439,7 +3388,6 @@ mod tests {
let mut s = solidcraft_engine::Session::default();
s.run_script(&solidcraft_engine::sample::script()).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
const SIZE: u32 = 256;
@@ -3498,7 +3446,6 @@ mod tests {
s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap();
s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
const SIZE: u32 = 256;
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
@@ -3599,13 +3546,13 @@ mod tests {
crate::sketch_palette::set_mode(&mut h.app, 0);
}
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974)
/// and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
/// Headless pictures of the view (`ui.render`, the agent's view image) are drawn in the
/// window's theme (#974) and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
/// light picture in Off is unchanged; otherwise each part takes the token the window draws it
/// with. Sketch states follow what the window does with them (`sketch_color`) and the axes the
/// mode's triad, so the two can't drift apart.
#[test]
fn cpu_pictures_are_drawn_in_the_windows_theme() {
fn pictures_are_drawn_in_the_windows_theme() {
use crate::theme::ColorVision;
use solidcraft_engine::view::Palette;
assert_eq!(scene_palette(false, ColorVision::Off), Palette::DEFAULT, "the light theme is the engine's colours");
@@ -3620,6 +3567,7 @@ mod tests {
for (dark, mode) in [false, true].into_iter().flat_map(|d| ColorVision::ALL.map(|m| (d, m))) {
let (p, t) = (scene_palette(dark, mode), Tokens::themed(dark, mode));
assert_eq!(p.axes, t.triad.map(rgb), "dark {dark}, {mode:?}: the axes");
assert_eq!(p.analysis, t.analysis, "dark {dark}, {mode:?}: the surface analysis");
for (tag, cpu) in [
(colors::SKETCH, p.sketch),
(colors::SKETCH_FIXED, p.sketch_fixed),
@@ -3636,65 +3584,43 @@ mod tests {
}
}
/// The CPU viewport follows a theme or colour vision mode change at once: both are part of its
/// picture's key.
/// The view's picture follows a theme change: its background is the window's.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cpu_viewport_redraws_in_the_new_theme() {
fn the_view_picture_follows_the_theme() {
if !crate::gpu::test_picture_renderer() {
return;
}
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(160.0, 120.0));
let cam = solidcraft_engine::view::home_camera(&h.app.session);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
h.app.viewport.no_pixels = false;
// The new picture's top row, most common colour first (the background's top); None when
// nothing new was drawn.
let top_row = |h: &mut crate::scenario::Harness| {
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
});
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
let top = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map(|upload| {
let egui::ImageData::Color(image) = &upload.image;
let mut counts = std::collections::HashMap::new();
for px in image.pixels.iter().take(image.size[0]) {
*counts.entry(*px).or_insert(0) += 1;
}
counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default()
});
out.textures_delta.clear();
top
};
h.app.cam = solidcraft_engine::view::home_camera(&h.app.session);
for dark in [false, true, false] {
h.app.ui.dark = dark;
let Some(px) = top_row(&mut h) else { panic!("dark {dark}: the CPU viewport kept the old picture") };
let img = crate::control::view_rgba(&h.app, 160, 120).unwrap();
// The top row's most common colour: the background's top.
let mut counts = std::collections::HashMap::new();
for px in img.as_chunks::<4>().0.iter().take(160) {
*counts.entry([px[0], px[1], px[2]]).or_insert(0) += 1;
}
let px = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default();
let want = if dark { Tokens::dark() } else { Tokens::light() }.viewport_top;
let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
let near = px.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}");
}
// And a colour vision mode change, on its own.
h.app.preferences.color_vision = crate::theme::ColorVision::Deutan;
assert!(top_row(&mut h).is_some(), "a new mode kept the old picture");
assert!(top_row(&mut h).is_none(), "no change, no new picture");
}
/// The view's picture is cut by the section the viewport shows: an open Section Analysis
/// dialog's preview, a sketch's Slice.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cpu_viewport_uses_section_preview_and_sketch_slice() {
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<Color32> {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
h.app.viewport.no_pixels = false;
// Force a texture upload so this test reads the actual CPU rasterizer result.
h.app.viewport.cpu = None;
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
});
let id = h.app.viewport.cpu.as_ref().unwrap().1.id();
let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap();
let egui::ImageData::Color(image) = &upload.image;
assert_eq!(image.size, [320, 240]);
let pixels = image.pixels.clone();
out.textures_delta.clear();
pixels
fn the_view_picture_uses_section_preview_and_sketch_slice() {
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<[u8; 4]> {
h.app.cam = cam;
let img = crate::control::view_rgba(&h.app, 320, 240).unwrap();
img.as_chunks::<4>().0.to_vec()
}
if !crate::gpu::test_picture_renderer() {
return;
}
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
@@ -3708,12 +3634,12 @@ mod tests {
*offset = "15 mm".into();
assert!(h.app.session.section.is_none());
let preview = pixels(&mut h, cam);
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview");
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "the picture ignored the uncommitted section preview");
assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap();
h.app.dialog = None;
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically");
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must draw identically");
h.app.session.section = None;
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
@@ -3721,7 +3647,7 @@ mod tests {
h.app.run("sketch.options", json!({"slice":true})).unwrap();
assert!(h.app.session.section.is_none());
let sliced = pixels(&mut h, cam);
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice");
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "the picture ignored the active Sketch Slice");
h.app.run("sketch.options", json!({"slice":false})).unwrap();
assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels");
}
@@ -3876,7 +3802,6 @@ mod tests {
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
if gpu {
// The callback is queued by egui; no device is needed to inspect scene updates.
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
@@ -4106,7 +4031,6 @@ mod tests {
s.selection = vec![Sel::Body { name: "Body1".into() }];
let mut app = SolveApp::new(s, crate::Services::default());
app.preferences.nav = "touchpad".into();
app.viewport.no_pixels = true;
app.start("solid.move");
app.dialog.as_mut().unwrap().focus = false;
let p = pos2(100.0, 100.0);
@@ -4341,7 +4265,6 @@ mod tests {
};
for tess in [true, false] {
let mut app = cylinder_app();
app.viewport.no_pixels = true;
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
let body = app.session.world_state().bodies[0].clone();
let before = build_scene(&app).merged();
@@ -4444,8 +4367,9 @@ mod tests {
if wireframe {
scene.meshes.clear();
}
let png = solidcraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap();
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap();
assert!(crate::gpu::test_picture_renderer(), "SMOOTH_SCREENSHOTS needs a GPU");
let png = solidcraft_engine::capture::png(&scene, &cam, 1200, 800).unwrap();
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png.bytes).unwrap();
}
}
}
@@ -4846,7 +4770,6 @@ mod tests {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
assert!(app.preferences.trackpad_pan);
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let pan =
@@ -4905,7 +4828,6 @@ mod tests {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.preferences.smooth_wheel_zoom = smooth;
app.preferences.trackpad_pan = false;
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
@@ -5396,7 +5318,6 @@ mod tests {
let mut s = solidcraft_engine::Session::default();
s.run_script(&solidcraft_engine::sample::script()).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
app.run("inspect.draft", json!({"pull": [-1, -1, 0]})).unwrap();
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
@@ -5448,46 +5369,39 @@ mod tests {
assert!(changed.iter().all(|&i| b[4 * i..4 * i + 3].iter().zip(&c[4 * i..4 * i + 3]).all(|(x, y)| x <= y)), "stripes are darker");
}
/// The CPU viewport draws in the colour vision mode: a fixed sketch line in the light theme
/// The view's picture draws in the colour vision mode: a fixed sketch line in the light theme
/// takes Deutan's value, and Off's is gone.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cpu_viewport_draws_in_the_colour_vision_mode() {
fn the_view_picture_draws_in_the_colour_vision_mode() {
use crate::theme::ColorVision;
if !crate::gpu::test_picture_renderer() {
return;
}
let mut h = crate::scenario::Harness::new();
h.app.ui.dark = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
let mut cam = solidcraft_engine::view::home_camera(&h.app.session);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
h.app.viewport.no_pixels = false;
let count = |h: &mut crate::scenario::Harness, c: Color32| {
h.app.viewport.cpu = None;
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
});
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
let n = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map_or(0, |upload| {
let egui::ImageData::Color(image) = &upload.image;
image.pixels.iter().filter(|p| [p.r(), p.g(), p.b()].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
});
out.textures_delta.clear();
n
h.app.cam = cam;
let count = |h: &crate::scenario::Harness, c: Color32| {
let img = crate::control::view_rgba(&h.app, 320, 240).unwrap();
img.as_chunks::<4>().0.iter().filter(|p| p[..3].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
};
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
let (a, b) = (count(&h, off), count(&h, deutan));
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
h.app.preferences.color_vision = ColorVision::Deutan;
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
let (a, b) = (count(&h, off), count(&h, deutan));
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
}
/// A mode's table reaches the CPU picture: the sketch states and axes it changes, and nothing
/// else (the light theme in Off is the engine's own colours).
/// A mode's table reaches the headless picture's palette: the sketch states and axes it
/// changes, and nothing else (the light theme in Off is the engine's own colours).
#[test]
fn cpu_picture_follows_the_colour_vision_mode() {
fn picture_palette_follows_the_colour_vision_mode() {
use crate::theme::ColorVision;
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
let off = scene_palette(false, ColorVision::Off);
@@ -5505,6 +5419,7 @@ mod tests {
/// The mode is on from the next frame (theme, widgets, caches, `ui.render`), and Cancel puts
/// it back.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn colour_vision_mode_reaches_the_frame_and_the_view_caches() {
use crate::theme::ColorVision;
@@ -5524,8 +5439,11 @@ mod tests {
h.frame();
assert_eq!((h.app.preferences.color_vision, crate::theme::vision()), (ColorVision::Off, ColorVision::Off));
assert_eq!(keys(&h.app), before);
// `ui.render` (the CPU picture) shows the mode too: a fixed sketch line in Deutan's value
// (in the light theme, where the red-green table changes it; the render follows the theme).
// `ui.render` shows the mode too: a fixed sketch line in Deutan's value (in the light
// theme, where the red-green table changes it; the render follows the theme).
if !crate::gpu::test_picture_renderer() {
return;
}
h.call("ui.prefs", json!({"theme": "light"}));
h.frame();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();