Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)
Thanks @jyviko.
This commit is contained in:
1 parent
48322f6464
commit
1a0b33fcf3
54 files changed
+3525
-1998
No files matched your search
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user