diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index e2d03db..e7d350f 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -10,7 +10,8 @@ use fieldcraft_model::ElementKind; use fieldcraft_render::contour::{Bands, band_color, split_triangle}; -use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Rgb, Scene, SceneLine, SceneTri, StandardView, Vec3}; +use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3}; +use serde_json::Value; use crate::results::{displacements, field_values, magnitude, range}; use crate::{EngineError, Result, Session}; @@ -174,12 +175,31 @@ pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) { (scene, bounds, shown) } -/// A PNG of the session seen from `view`, framing everything drawn, `w` × `h` pixels. -pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palette) -> Result<(Vec, Shown)> { +/// A camera orientation from JSON, `{"yaw": …, "pitch": …, "distance"?: …}`: degrees, and the +/// distance from the target in model units (the control channel's `ui.camera`, the MCP +/// `screenshot` tool's `camera`). Hostile input is refused or clamped +/// ([`Orientation::checked`]). +pub fn orientation(p: &Value) -> Result { + let bad = |m: &str| EngineError::Params(format!("camera: {m}")); + let o = p.as_object().ok_or_else(|| bad("an object {yaw, pitch, distance?} in degrees"))?; + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "yaw" | "pitch" | "distance")) { + return Err(bad(&format!("unknown field `{}` (yaw, pitch, distance)", k.chars().take(40).collect::()))); + } + let number = |k: &str| o.get(k).filter(|v| !v.is_null()).map(|v| v.as_f64().ok_or_else(|| bad(&format!("`{k}` must be a number")))); + let yaw = number("yaw").ok_or_else(|| bad("`yaw` (degrees) is required"))??; + let pitch = number("pitch").ok_or_else(|| bad("`pitch` (degrees) is required"))??; + let distance = number("distance").transpose()?; + Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0")) +} + +/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn, or from +/// `at`'s distance when it has one. +pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec, Shown)> { let (mut scene, bounds, shown) = scene(s, p); let mut cam = Camera::default(); - cam.set_view(view); + cam.orient(&Orientation { distance: None, ..*at }); cam.fit(&bounds); + cam.orient(at); scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) }; let png = fieldcraft_render::render_png(&scene, &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?; Ok((png, shown)) @@ -187,25 +207,56 @@ pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palet #[cfg(test)] mod tests { + use fieldcraft_render::StandardView; use serde_json::json; use super::*; #[test] fn renders_the_model_and_its_results() { + let iso = Orientation::of(StandardView::Iso); let mut s = Session::default(); - let empty = render_png(&s, StandardView::Iso, 32, 24, &Palette::LIGHT).unwrap(); + let empty = render_png(&s, &iso, 32, 24, &Palette::LIGHT).unwrap(); assert!(empty.0.starts_with(&[0x89, b'P', b'N', b'G'])); s.run_script(&crate::sample::cantilever()).unwrap(); - let (model, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap(); + let (model, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap(); assert_eq!(shown, Shown::default()); s.execute("solve.static", &json!({})).unwrap(); - let (plot, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap(); + let (plot, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap(); assert_ne!(model, plot); assert!(shown.scale > 0.0 && shown.step.is_some(), "{shown:?}"); let (field, min, max) = shown.contour.unwrap(); assert_eq!(field, "von_mises"); assert!(max.1 > min.1); - assert!(render_png(&s, StandardView::Top, 0, 48, &Palette::LIGHT).is_err()); + assert!(render_png(&s, &Orientation::of(StandardView::Top), 0, 48, &Palette::LIGHT).is_err()); + } + + #[test] + fn camera_orientation_is_checked() { + let o = orientation(&json!({"yaw": -45, "pitch": 30})).unwrap(); + assert_eq!((o.yaw, o.pitch, o.distance), (-45.0, 30.0, None)); + let o = orientation(&json!({"yaw": 400, "pitch": -400, "distance": 1e300})).unwrap(); + assert_eq!((o.yaw, o.pitch, o.distance), (40.0, -90.0, Some(Orientation::DISTANCE.1))); + for hostile in [ + json!(null), + json!([1, 2]), + json!({}), + json!({"yaw": 0}), + json!({"pitch": 0}), + json!({"yaw": "left", "pitch": 0}), + json!({"yaw": 0, "pitch": 0, "distance": 0}), + json!({"yaw": 0, "pitch": 0, "distance": -5}), + json!({"yaw": 0, "pitch": 0, "distance": "far"}), + json!({"yaw": 0, "pitch": 0, "roll": 3}), + json!({"yaw": f64::INFINITY, "pitch": 0}), + ] { + assert!(orientation(&hostile).is_err(), "{hostile}"); + } + // A picture from a chosen orientation and distance differs from the framed iso one. + let mut s = Session::default(); + s.run_script(&crate::sample::cantilever()).unwrap(); + let iso = render_png(&s, &Orientation::of(StandardView::Iso), 64, 48, &Palette::LIGHT).unwrap().0; + let side = render_png(&s, &orientation(&json!({"yaw": -80, "pitch": 10, "distance": 2000})).unwrap(), 64, 48, &Palette::LIGHT).unwrap().0; + assert_ne!(iso, side); } } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 4cf224b..ba8ea6c 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,6 +1,6 @@ //! Where tool calls end up: an in-process headless session, or a running app's control channel -//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.render`, -//! `ui.capture`). +//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.camera`, +//! `ui.render`, `ui.capture`). use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; @@ -11,7 +11,7 @@ use std::time::Duration; use fieldcraft_engine::Session; use fieldcraft_engine::view::{Palette, render_png}; use fieldcraft_io::roots::Roots; -use fieldcraft_render::StandardView; +use fieldcraft_render::{Orientation, StandardView}; use serde_json::{Value, json}; /// What a picture shows. @@ -19,6 +19,8 @@ use serde_json::{Value, json}; pub struct PictureRequest { /// A standard view to turn to first (`iso`, `front`…; `home` and `fit` frame the model). pub view: Option, + /// A camera orientation to turn to first (degrees; `view` and `camera` are not given together). + pub camera: Option, /// The whole app window (`true`) or the model only. pub window: bool, /// Image size in pixels (a window capture: points); `None` keeps the default. @@ -67,8 +69,9 @@ fn standard_view(v: &str) -> Result { /// An in-process headless session; pictures come from the CPU rasterizer. pub struct Headless { pub session: Session, - /// The orientation of pictures; each one frames everything drawn. - pub view: StandardView, + /// The orientation of pictures; each one frames everything drawn, unless the orientation + /// has a distance. + pub view: Orientation, roots: Roots, } @@ -83,7 +86,7 @@ impl Headless { pub fn new(roots: Roots) -> Self { let mut session = Session::default(); session.file_access = roots.clone(); - Headless { session, view: StandardView::Iso, roots } + Headless { session, view: Orientation::of(StandardView::Iso), roots } } } @@ -98,11 +101,14 @@ impl Backend for Headless { fn picture(&mut self, r: &PictureRequest) -> Result { if let Some(v) = &r.view { - self.view = standard_view(v)?; + self.view = Orientation::of(standard_view(v)?); + } + if let Some(o) = r.camera { + self.view = o; } let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE); let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE); - let (png, shown) = render_png(&self.session, self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?; + let (png, shown) = render_png(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?; let contour = shown .contour .map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}})); @@ -229,6 +235,9 @@ impl Backend for Remote { standard_view(v)?; self.call("ui.view", json!({"view": v}))?; } + if let Some(o) = r.camera { + self.call("ui.camera", json!(o))?; + } let path = temp_png(); let shown = path.to_string_lossy().into_owned(); let mut params = json!({"path": shown}); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c20b68a..9237e41 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -173,6 +173,12 @@ fn hostile_tool_arguments_are_tool_errors() { ("probe", json!({"nodes": [1, 2, 3]})), ("reactions", json!({"sets": ["nope"], "about": [1e308, 0, 0]})), ("screenshot", json!({"view": "iso", "path": "/no/such/dir/x.png"})), + ("screenshot", json!({"camera": {"yaw": "left", "pitch": 0}})), + ("screenshot", json!({"camera": {"yaw": 0}})), + ("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "distance": -1}})), + ("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "distance": 0}})), + ("screenshot", json!({"camera": {"yaw": 0, "pitch": 0, "zoom": 2}})), + ("screenshot", json!({"camera": {"yaw": 0, "pitch": 0}, "view": "top"})), ("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})), ("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})), ("undo", json!({})), @@ -192,6 +198,26 @@ fn hostile_tool_arguments_are_tool_errors() { assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); } +/// `screenshot {camera}` turns a headless picture to any orientation: hostile angles are clamped, +/// and the orientation is kept for the next picture. +#[test] +fn screenshot_takes_a_camera_orientation() { + let mut s = server(); + ok(&mut s, "execute", json!({"command": "file.sample", "params": {"name": "cantilever"}})); + let picture = |s: &mut Server, args: Value| { + let r = send(s, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": args}})); + let content = r["result"]["content"].as_array().cloned().unwrap_or_default(); + let info: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap(); + (content[0]["data"].as_str().unwrap().to_string(), info) + }; + let (iso, _) = picture(&mut s, json!({"view": "iso", "width": 64, "height": 48})); + let (turned, info) = picture(&mut s, json!({"camera": {"yaw": 30, "pitch": 1e9, "distance": 1e300}, "width": 64, "height": 48})); + assert_ne!(iso, turned); + assert_eq!(info["view"], json!({"yaw": 30.0, "pitch": 90.0, "distance": 1e9}), "{info}"); + let (again, _) = picture(&mut s, json!({"width": 64, "height": 48})); + assert_eq!(again, turned); +} + #[test] fn a_batch_stops_at_the_first_error() { let mut s = server(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6d7a2ee..1a373c0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -272,10 +272,13 @@ pub fn tool_definitions() -> Vec { "Look at the model", "A PNG of the model and the results display. Headless: a CPU render framing everything drawn. Connected to \ the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with the \ - app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first.", + app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first, or `camera` to \ + any orientation: {yaw, pitch, distance?} in degrees (yaw 0 looks along +Y, -45 is the iso view's; pitch is \ + the elevation, clamped to ±90) and model units; a sequence of them orbits smoothly.", obj( json!({ "view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"}, + "camera": {"type": "object", "description": "Camera orientation {yaw, pitch, distance?}: degrees, and the distance from the target in model units (default: framed headless, kept in the app)"}, "source": {"type": "string", "enum": ["window", "model"], "description": "window = the app's window, model = a model render"}, "width": integer("Width in pixels (window: points), 16 to 4096"), "height": integer("Height in pixels (window: points), 16 to 4096"), @@ -525,8 +528,13 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu None => None, }; let size = |k: &str| a.get(k).and_then(Value::as_u64).map(|v| u32::try_from(v).unwrap_or(u32::MAX)); + let camera = a.get("camera").filter(|v| !v.is_null()).map(fieldcraft_engine::view::orientation).transpose().map_err(|e| e.to_string())?; + if camera.is_some() && a.get("view").is_some_and(|v| !v.is_null()) { + return Err("screenshot: give `view` or `camera`, not both".into()); + } let request = PictureRequest { view: str_arg(a, "view").map(str::to_string), + camera, window: str_arg(a, "source") != Some("model"), width: size("width"), height: size("height"), diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 38487ea..8b0117f 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -99,6 +99,40 @@ impl StandardView { } } +/// Where a camera looks from, in degrees as people and agents give it: `yaw` turns about Z (0 +/// looks along +Y, the front view; −45 is the iso view's), `pitch` is the elevation above the XY +/// plane and `distance`, when given, the distance from the target (a zoom in orthographic views). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Orientation { + pub yaw: f64, + pub pitch: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub distance: Option, +} + +impl Orientation { + /// Closest and farthest camera distance. + pub const DISTANCE: (f64, f64) = (1e-3, 1e9); + + /// The orientation of a standard view. + pub fn of(view: StandardView) -> Self { + let (yaw, pitch) = view.angles(); + Orientation { yaw: yaw.to_degrees(), pitch: pitch.to_degrees(), distance: None } + } + + /// A checked orientation: `None` when an angle or the distance is not finite or the distance + /// is not above 0. The yaw is wrapped into [−180, 180), the pitch clamped to ±90 and the + /// distance to [`Orientation::DISTANCE`]. + pub fn checked(yaw: f64, pitch: f64, distance: Option) -> Option { + if !(yaw.is_finite() && pitch.is_finite()) || distance.is_some_and(|d| !(d.is_finite() && d > 0.0)) { + return None; + } + let yaw = (yaw + 180.0).rem_euclid(360.0) - 180.0; + let (near, far) = Self::DISTANCE; + Some(Orientation { yaw, pitch: pitch.clamp(-90.0, 90.0), distance: distance.map(|d| d.clamp(near, far)) }) + } +} + /// Orbit camera around `target`, Z up. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Camera { @@ -241,6 +275,18 @@ impl Camera { self.yaw = y; self.pitch = p.clamp(-lim, lim); } + /// Turn to `o` (and move to its distance, when it has one); the pitch stays just short of the + /// poles, as an orbit's does. + pub fn orient(&mut self, o: &Orientation) { + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + if o.yaw.is_finite() && o.pitch.is_finite() { + self.yaw = o.yaw.to_radians(); + self.pitch = o.pitch.to_radians().clamp(-lim, lim); + } + if let Some(d) = o.distance.filter(|d| d.is_finite() && *d > 0.0) { + self.distance = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1); + } + } /// Frame a bounding box. pub fn fit(&mut self, b: &Aabb3) { if b.is_empty() { @@ -324,4 +370,31 @@ mod tests { assert_eq!(StandardView::parse(v.label()), Some(v)); } } + + #[test] + fn orientation_in_degrees() { + // The iso view's angles, given in degrees, turn the camera as the iso view does. + let (mut a, mut b) = (Camera::default(), Camera::default()); + a.set_view(StandardView::Front); + a.orient(&Orientation::of(StandardView::Iso)); + b.set_view(StandardView::Iso); + assert!((a.yaw - b.yaw).abs() < 1e-12 && (a.pitch - b.pitch).abs() < 1e-12); + let o = Orientation::checked(-45.0, 35.264, Some(300.0)).unwrap(); + a.orient(&o); + assert_eq!(a.distance, 300.0); + // Wrapped, clamped, refused. + let o = Orientation::checked(540.0, 1e6, Some(1e300)).unwrap(); + assert_eq!((o.yaw, o.pitch, o.distance), (-180.0, 90.0, Some(Orientation::DISTANCE.1))); + a.orient(&o); + assert!(a.is_valid() && a.pitch < std::f64::consts::FRAC_PI_2, "{a:?}"); + for (y, p, d) in + [(f64::NAN, 0.0, None), (0.0, f64::INFINITY, None), (0.0, 0.0, Some(0.0)), (0.0, 0.0, Some(-1.0)), (0.0, 0.0, Some(f64::NAN))] + { + assert_eq!(Orientation::checked(y, p, d), None); + } + // A hostile orientation built by hand leaves the camera as it was. + let before = a; + a.orient(&Orientation { yaw: f64::NAN, pitch: 0.0, distance: Some(f64::INFINITY) }); + assert_eq!(a, before); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 86d721c..4afee39 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,7 +11,7 @@ mod display; mod raster; mod vec; -pub use camera::{Camera, Mat4, StandardView}; +pub use camera::{Camera, Mat4, Orientation, StandardView}; pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri}; pub use raster::{ Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 434f202..90a2e2b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -184,6 +184,13 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> } ok(json!(app.cam)) } + "ui.camera" => match fieldcraft_engine::view::orientation(p) { + Ok(o) => { + app.cam.orient(&o); + ok(json!(app.cam)) + } + Err(e) => err(e), + }, "ui.start" => { let Some(id) = s("command") else { return err("missing `command`") }; match app.start_programmatic(id) { @@ -427,6 +434,21 @@ mod tests { assert_eq!(call(&mut app, "ui.view", json!({"view": "top"}))["ok"], true); assert!((app.cam.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3); assert_eq!(call(&mut app, "ui.view", json!({"view": "sideways"}))["ok"], false); + // Any orientation in degrees; hostile numbers are refused or clamped and leave a valid camera. + let r = call(&mut app, "ui.camera", json!({"yaw": 90, "pitch": 30, "distance": 250})); + assert!((app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-9 && (app.cam.pitch - 30f64.to_radians()).abs() < 1e-9, "{r}"); + assert_eq!(r["result"]["distance"], 250.0); + for hostile in [ + json!({"yaw": 1}), + json!({"yaw": "x", "pitch": 0}), + json!({"yaw": 0, "pitch": 0, "distance": 0}), + json!({"yaw": 0, "pitch": 0, "spin": 1}), + ] { + assert_eq!(call(&mut app, "ui.camera", hostile.clone())["ok"], false, "{hostile}"); + } + assert_eq!(app.cam.distance, 250.0); + assert_eq!(call(&mut app, "ui.camera", json!({"yaw": -1e12, "pitch": 1e300, "distance": 1e300}))["ok"], true); + assert!(app.cam.is_valid() && app.cam.pitch < std::f64::consts::FRAC_PI_2); assert_eq!(call(&mut app, "ui.inspect", json!({}))["result"]["model"]["elements"], 64 + 162); assert_eq!(call(&mut app, "ui.screenshot", json!({})), json!({"screenshot": true})); assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false); @@ -495,6 +517,7 @@ mod tests { "engine.script", "ui.set", "ui.view", + "ui.camera", "ui.start", "ui.dialog", "ui.move", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 38e701e..ff972d5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,6 +39,7 @@ same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impa | `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | +| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | | `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form | | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | diff --git a/docs/mcp.md b/docs/mcp.md index bf2199b..7983a7b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,7 +104,7 @@ software on the computer. | `show_results` | `step?`, `field?`, `bands?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for; the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. | | `probe` | `step?`, `nodes?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, elements (explicit) or points (fluid). | | `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. | -| `screenshot` | `view?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`); `view` turns the app's camera first. | +| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`). `view` turns the camera to a standard view first; `camera` `{yaw, pitch, distance?}` to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (`ui.camera`; headless, the picture is framed unless `distance` is given). Give one of the two; the orientation is kept for the next picture, so a sequence of `camera` angles orbits smoothly. | | `undo`, `redo` | — | `edit.undo`, `edit.redo`. | Resources: `fieldcraft://model` (`model.info`) and `fieldcraft://commands` (the command catalog).