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

+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, .. })) => {