feat: view.apply restores a named view or camera; MCP screenshot takes a named view or camera (#528) (#595)

New view.apply command restores a named view, a standard view or any camera (view state, not an undo step); the desktop viewport follows it and the browser's Restore uses it. The MCP screenshot tool takes a named view or a camera. Un-ignores the #528 regression test. Fixes #528.

Co-authored-by: jonaswre <[email protected]>
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 05:22:52 -04:00
1 parent e67cc0c747
commit 27f20f97cb
11 files changed
+159 -23

No files matched your search

+19 -6
View File
@@ -144,16 +144,29 @@ impl Backend for Headless {
"document.inspect" => s.execute("document.inspect", &p).map_err(|e| e.to_string()),
"ui.view" => {
let v = str_p("view").unwrap_or("home");
self.view = match v {
"fit" => self.view,
_ => StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?,
};
match v {
// Fit keeps the orientation, also of a camera `view.apply` chose.
"fit" => {
let b = solidcraft_engine::view::bounds(s);
if let Some(c) = s.camera.as_mut() {
c.fit(&b);
}
}
_ => {
self.view = StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?;
s.camera = None;
}
}
Ok(json!({"view": self.view}))
}
"ui.render" => {
let (w, h) = (dim(&p, "width", 1280.0), dim(&p, "height", 800.0));
let mut cam = solidcraft_engine::view::home_camera(s);
cam.set_view(self.view);
// The camera `view.apply` chose, else the standard view framing the model.
let cam = s.camera.unwrap_or_else(|| {
let mut cam = solidcraft_engine::view::home_camera(s);
cam.set_view(self.view);
cam
});
let scene = solidcraft_engine::view::scene(s, &cam);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
let path = str_p("path").ok_or("missing `path`")?;
+10 -3
View File
@@ -313,7 +313,11 @@ fn unknown_tools_and_bad_arguments_are_clean_errors() {
assert_eq!(raw_tool(&mut s, "execute", json!({}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "execute", json!({"command": 5}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "measure", json!({"bogus": 1}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "screenshot", json!({"view": "sideways"}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "screenshot", json!({"source": "sideways"}))["error"]["code"], -32602);
// `view` also takes saved named views, so an unknown name is a tool error naming it.
let r = raw_tool(&mut s, "screenshot", json!({"view": "sideways"}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("sideways"), "{r}");
assert_eq!(call(&mut s, 1, "tools/call", json!({"arguments": {}}))["error"]["code"], -32602);
// Engine errors are tool errors the model can read.
let r = raw_tool(&mut s, "execute", json!({"command": "solid.extrude", "params": {"distance": 10}}));
@@ -439,12 +443,11 @@ fn legacy_design_files_and_resource_uris() {
/// model from that view after each change. The render must use the saved camera, not a
/// standard view.
#[test]
#[ignore = "fails until #528 is fixed"]
fn screenshot_renders_a_saved_named_view() {
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});
tool(&mut s, "execute", json!({"command": "view.save", "params": {"name": "Photo", "camera": camera}}));
tool(&mut s, "execute", json!({"command": "view.save", "params": {"name": "Photo", "camera": camera.clone()}}));
let shot = |s: &mut Server, view: &str| {
let img = tool(s, "screenshot", json!({"view": view, "width": 96, "height": 64}));
img["data"].as_str().unwrap_or_default().to_string()
@@ -453,6 +456,10 @@ fn screenshot_renders_a_saved_named_view() {
let photo = shot(&mut s, "Photo");
assert!(photo.starts_with("iVBORw0KGgo"), "not a PNG");
assert_ne!(photo, iso, "the named view rendered like the standard iso view");
// The same camera given directly renders the same picture; a standard view turns back.
let direct = tool(&mut s, "screenshot", json!({"camera": camera, "width": 96, "height": 64}));
assert_eq!(direct["data"].as_str().unwrap_or_default(), photo);
assert_eq!(shot(&mut s, "iso"), iso);
}
/// #530: in a design with many bodies every feature result lists all of them, so a batch reply
+22 -7
View File
@@ -155,10 +155,13 @@ 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.",
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply).",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
"view": string(
"Standard view (iso, home, front, back, top, bottom, left, right, fit) or the name of a saved named view (default: keep the current one; headless starts at iso)"
),
"camera": {"type": "object", "description": "Look from this camera instead: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm; as view.save and view.apply)"},
"path": string("Also keep the PNG at this path"),
"width": {"type": "integer", "description": "Render width in pixels (model renders; default 960)"},
"height": {"type": "integer", "description": "Render height in pixels (model renders; default 600)"},
@@ -513,12 +516,24 @@ 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> {
if let Some(v) = str_arg(a, "view") {
b.call("ui.view", json!({"view": v}))?;
// Turning to a standard view also frames the model (like a axis view click).
if v != "fit" && v != "home" {
b.call("ui.view", json!({"view": "fit"}))?;
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()),
(None, Some(c)) => {
apply(b, json!({"camera": c}))?;
}
(Some(v), None) if VIEWS.contains(&v) => {
b.call("ui.view", json!({"view": v}))?;
// Turning to a standard view also frames the model (like a axis view click).
if v != "fit" && v != "home" {
b.call("ui.view", json!({"view": "fit"}))?;
}
}
// A saved named view.
(Some(v), None) => {
apply(b, json!({"view": v}))?;
}
(None, None) => {}
}
let keep = str_arg(a, "path").filter(|p| !p.trim().is_empty()).map(str::to_string);
let path = keep.clone().unwrap_or_else(|| {