snapshot --camera and saved views; grid: false / axes: false for headless renders (#1313)

solidcraft-cli snapshot takes --camera JSON and --view with a saved view name (through view.apply, as the MCP screenshot does), plus --no-grid and --no-axes; the MCP screenshot and ui.render take grid/axes, the headless render skips a hidden origin axis, and the app's ui.render follows the Grid and Origin switches. Closes #1245.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 02:59:56 -04:00
1 parent d824d36821
commit b3df2c382b
10 files changed
+569 -54

No files matched your search

+5 -1
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::view::Guides;
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`.
@@ -275,7 +276,10 @@ impl Backend for Headless {
cam.set_view(self.view);
cam
});
let scene = solidcraft_engine::view::scene(s, &cam);
// 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}"))?;
+91
View File
@@ -705,6 +705,97 @@ fn screenshot_renders_a_saved_named_view() {
assert_eq!(shot(&mut s, "iso"), iso);
}
/// The ground grid lies on Z 0 and cuts through a part that straddles it: `grid: false` and
/// `axes: false` leave it and the origin's axes out of the render. An axis hidden with
/// `browser.visibility` stays out whatever `axes` says.
#[test]
fn screenshot_can_leave_the_grid_and_the_axes_out() {
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| {
let mut args = json!({"view": "front", "width": 96, "height": 64});
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
tool(s, "screenshot", args)["data"].as_str().unwrap_or_default().to_string()
};
let all = shot(&mut s, json!({}));
let no_grid = shot(&mut s, json!({"grid": false}));
let no_axes = shot(&mut s, json!({"axes": false}));
let bare = shot(&mut s, json!({"grid": false, "axes": false}));
assert!(all.starts_with("iVBORw0KGgo"), "not a PNG");
let pictures = [&all, &no_grid, &no_axes, &bare];
for (i, a) in pictures.iter().enumerate() {
for b in &pictures[i + 1..] {
assert_ne!(a, b, "two of the pictures are the same");
}
}
// Asking for them is the default.
assert_eq!(shot(&mut s, json!({"grid": true, "axes": true})), all);
// Hiding the origin leaves the axes out, with or without `axes`; the grid is not the origin.
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "origin", "visible": false}}));
assert_eq!(shot(&mut s, json!({})), no_axes);
assert_eq!(shot(&mut s, json!({"axes": true})), no_axes);
assert_eq!(shot(&mut s, json!({"grid": false})), bare);
// Other than booleans are schema errors.
for bad in [json!({"grid": "no"}), json!({"axes": 0})] {
assert_eq!(raw_tool(&mut s, "screenshot", bad)["error"]["code"], -32602);
}
}
/// Bridged to an app, `grid` and `axes` ask for a model render and travel with it; without them
/// the window is captured as before; the window cannot be asked to leave things out.
#[test]
fn bridged_screenshots_with_grid_or_axes_are_model_renders() {
struct App {
calls: Vec<(String, Value)>,
}
impl crate::backend::Backend for App {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.calls.push((method.to_string(), params.clone()));
match method {
"ui.render" | "ui.screenshot" => {
let path = params["path"].as_str().ok_or("missing `path`")?;
std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": 64, "height": 48}))
}
other => Err(format!("unknown method `{other}`")),
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"app".into()
}
}
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-guides-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png").to_string_lossy().to_string();
let shoot = |extra: Value| {
let mut args = json!({"path": path, "width": 64, "height": 48});
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let mut app = App { calls: Vec::new() };
let r = crate::tools::call_tool(&mut app, "screenshot", &args);
(app.calls, r)
};
let (calls, r) = shoot(json!({}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["ui.screenshot"]);
let (calls, r) = shoot(json!({"grid": false, "axes": false}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false, "axes": false}))]);
let (calls, _) = shoot(json!({"axes": true}));
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "axes": true}))]);
let (calls, r) = shoot(json!({"source": "model", "grid": false}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false}))]);
// The window shows what it shows: nothing is called, and the error says what to ask for.
let (calls, r) = shoot(json!({"source": "window", "grid": false, "view": "top"}));
assert_eq!(r["isError"], json!(true), "{r}");
assert!(r["content"][0]["text"].as_str().unwrap().contains("source"), "{r}");
assert!(calls.is_empty(), "{calls:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #530: in a design with many bodies every feature result lists all of them, so a batch reply
/// is mostly the same body list once per step. A compact batch reply names only what each step
/// added or changed.
+21 -7
View File
@@ -171,7 +171,8 @@ pub fn tool_definitions() -> Vec<Value> {
"Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply).",
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply). \
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render.",
obj(
json!({
"view": string(
@@ -182,6 +183,8 @@ pub fn tool_definitions() -> Vec<Value> {
"width": {"type": "integer", "description": "Render width in pixels (model renders; default 960)"},
"height": {"type": "integer", "description": "Render height in pixels (model renders; default 600)"},
"source": {"type": "string", "enum": ["window", "model"], "description": "window = app screenshot, model = model render"},
"grid": {"type": "boolean", "description": "false: leave the ground grid out of the render. It lies on Z 0 and cuts through a part that straddles it. Model renders only: default true, or as the app shows it when connected; with `grid` or `axes` a connected screenshot is a model render, and source \"window\" is an error"},
"axes": {"type": "boolean", "description": "false: leave the origin's three axes out of the render (see `grid`). Axes hidden with browser.visibility stay hidden"},
}),
&[],
),
@@ -579,6 +582,16 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Value), String> {
// `grid` and `axes` shape a model render; the window shows what it shows.
let guides: Vec<(&str, bool)> = ["grid", "axes"].into_iter().filter_map(|k| a.get(k).and_then(Value::as_bool).map(|v| (k, v))).collect();
let window = match str_arg(a, "source") {
Some("model") => false,
Some("window") if !guides.is_empty() => {
return Err("screenshot: `grid` and `axes` apply to model renders; use source \"model\" (the window shows what it shows)".into());
}
Some("window") => true,
_ => b.has_ui() && guides.is_empty(),
};
let apply = |b: &mut dyn Backend, params: Value| b.call("engine.execute", json!({"command": "view.apply", "params": params}));
match (str_arg(a, "view"), a.get("camera").filter(|c| !c.is_null())) {
(Some(_), Some(_)) => return Err("screenshot: give `view` or `camera`, not both".into()),
@@ -604,13 +617,14 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
let dir = crate::scratch_dir().unwrap_or_else(vfs::temp_dir);
dir.join(format!("shot-{n}.png")).to_string_lossy().to_string()
});
let window = match str_arg(a, "source") {
Some("model") => false,
Some("window") => true,
_ => b.has_ui(),
};
let size = |k: &str, d: u64| a.get(k).and_then(Value::as_u64).unwrap_or(d).clamp(16, 4096);
let render = |b: &mut dyn Backend| b.call("ui.render", json!({"path": path, "width": size("width", 960), "height": size("height", 600)}));
let render = |b: &mut dyn Backend| {
let mut params = json!({"path": path, "width": size("width", 960), "height": size("height", 600)});
for (k, v) in &guides {
params[*k] = json!(v);
}
b.call("ui.render", params)
};
let (info, source) = if window {
match b.call("ui.screenshot", json!({"path": path})) {
Ok(v) => (v, "window"),