diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 61b0bfd..9feb88d 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.camera`, -//! `ui.render`, `ui.capture`). +//! `ui.render`, `ui.capture`, `ui.select`, `ui.selection`). use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; @@ -43,6 +43,8 @@ pub trait Backend { fn commands(&mut self) -> Result; /// A PNG of the model, or of the app's window when one is attached. fn picture(&mut self, request: &PictureRequest) -> Result; + /// A request to the app's window (`ui.select`, `ui.selection`): an error without one. + fn window(&mut self, method: &str, params: Value) -> Result; /// The folders file access is confined to (checked by the server for the files it writes /// itself). fn roots(&self) -> &Roots; @@ -124,6 +126,10 @@ impl Backend for Headless { Ok(Picture { png, info }) } + fn window(&mut self, method: &str, _params: Value) -> Result { + Err(format!("{method} needs the app's window: start the server with --connect to a running FieldCraft")) + } + fn roots(&self) -> &Roots { &self.roots } @@ -267,6 +273,10 @@ impl Backend for Remote { read } + fn window(&mut self, method: &str, params: Value) -> Result { + self.call(method, params) + } + fn roots(&self) -> &Roots { &self.roots } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 9237e41..71d6e2a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -78,6 +78,8 @@ fn initialize_lists_tools_and_resources() { "probe", "reactions", "screenshot", + "select", + "selection", "undo", "redo", ] { @@ -181,6 +183,9 @@ fn hostile_tool_arguments_are_tool_errors() { ("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]})), + ("select", json!({"filter": "faces"})), + ("select", json!({})), + ("selection", json!({})), ("undo", json!({})), ("redo", json!({})), ] { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d92acb9..f03b879 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -290,6 +290,41 @@ pub fn tool_definitions() -> Vec { true, false, ), + tool( + "select", + "Select in the viewport", + "Change the app window's viewport selection (needs --connect; ui.select): `filter` (faces, edges, vertices, \ + bodies, nodes, element_faces, elements), `mode` (replace, add, remove, toggle), `clear`, items by name and \ + label as model.set.create takes them (`geometry`, `nodes`, `elements`, `faces`), a pick at a window point \ + `at` [x, y] or the visible items in a box `box` [x0, y0, x1, y1]. Returns the selection; its `params` are \ + a set's members.", + obj( + json!({ + "filter": {"type": "string", "enum": ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"]}, + "box_mode": {"type": "boolean", "description": "Box selection on or off in the window"}, + "mode": {"type": "string", "enum": ["replace", "add", "remove", "toggle"]}, + "clear": {"type": "boolean", "description": "Empty the selection first"}, + "geometry": {"type": "array", "items": {"type": "object"}, "description": "[{body, face|edge|vertex}]"}, + "nodes": {"type": "array", "items": {"type": "integer"}}, + "elements": {"type": "array", "items": {"type": "integer"}}, + "faces": {"type": "array", "items": {"type": "object"}, "description": "[{element, face}]"}, + "at": {"type": "array", "items": {"type": "number"}, "description": "Window point [x, y] to pick at"}, + "box": {"type": "array", "items": {"type": "number"}, "description": "Window box [x0, y0, x1, y1]"}, + }), + &[], + ), + false, + false, + ), + tool( + "selection", + "Read the viewport selection", + "The app window's viewport selection (needs --connect; ui.selection): filter, items, `params` (the \ + selection as a set's members, ready for model.set.create) and what is under the pointer.", + obj(json!({}), &[]), + true, + false, + ), tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false), tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false), ] @@ -440,6 +475,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result show_results(b, a), "probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])), "reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])), + "select" => { + let p = pick(a, &["filter", "box_mode", "mode", "clear", "geometry", "nodes", "elements", "faces", "at", "box"]); + if p.as_object().is_none_or(|o| o.is_empty()) { + return Err("select: give `filter`, `mode`, `clear`, items, `at` or `box`".into()); + } + b.window("ui.select", p) + } + "selection" => b.window("ui.selection", json!({})), "undo" => b.execute("edit.undo", json!({})), "redo" => b.execute("edit.redo", json!({})), other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::())), diff --git a/docs/mcp.md b/docs/mcp.md index 473b1aa..a2f095b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -105,6 +105,8 @@ software on the computer. | `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?`, `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, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) 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. | +| `select` | `filter?`, `box_mode?`, `mode?`, `clear?`, `geometry?`, `nodes?`, `elements?`, `faces?`, `at?`, `box?` | Bridged only: changes the app's viewport selection (`ui.select`; control-protocol.md, Selection) and returns it. Headless it is an error: selection belongs to the window. | +| `selection` | — | Bridged only: the viewport selection (`ui.selection`), with `params`, the selection as a set's members for `model.set.create`. | | `undo`, `redo` | — | `edit.undo`, `edit.redo`. | Resources: `fieldcraft://model` (`model.info`) and `fieldcraft://commands` (the command catalog).