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:
1 parent
e67cc0c747
commit
27f20f97cb
11 files changed
+159
-23
No files matched your search
@@ -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
@@ -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
@@ -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(|| {
|
||||
|
||||
Reference in new issue
Block a user