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

+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).