701 lines
35 KiB
Rust
701 lines
35 KiB
Rust
//! MCP tool definitions and dispatch. Every tool is a thin wrapper over engine commands
|
|
//! (`engine.execute`) and the inspection / rendering control methods.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use serde_json::{Map, Value, json};
|
|
use solidcraft_engine::io::vfs;
|
|
|
|
use crate::backend::Backend;
|
|
|
|
/// Most commands in one `batch` call.
|
|
const MAX_BATCH: usize = 10_000;
|
|
/// Largest image returned by `screenshot`.
|
|
const MAX_IMAGE_BYTES: usize = 32 << 20;
|
|
|
|
const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left", "right", "fit"];
|
|
|
|
fn obj(props: Value, required: &[&str]) -> Value {
|
|
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
|
|
if !required.is_empty() {
|
|
o["required"] = json!(required);
|
|
}
|
|
o
|
|
}
|
|
|
|
/// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
|
|
/// auto-approve on these hints must not be told less than the truth (#205).
|
|
#[derive(Clone, Copy)]
|
|
enum Effect {
|
|
/// Only looks.
|
|
ReadOnly,
|
|
/// Changes the design in memory, undoably.
|
|
Edits,
|
|
/// Replaces the design in memory (unsaved changes are lost).
|
|
Replaces,
|
|
/// May read, write, overwrite or delete files on disk (any command, a file path).
|
|
Files,
|
|
}
|
|
|
|
fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
|
|
let (read_only, destructive, open_world) = match effect {
|
|
Effect::ReadOnly => (true, false, false),
|
|
Effect::Edits => (false, false, false),
|
|
Effect::Replaces => (false, true, false),
|
|
Effect::Files => (false, true, true),
|
|
};
|
|
json!({
|
|
"name": name,
|
|
"title": title,
|
|
"description": desc,
|
|
"inputSchema": schema,
|
|
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
|
|
})
|
|
}
|
|
|
|
fn string(desc: &str) -> Value {
|
|
json!({"type": "string", "description": desc})
|
|
}
|
|
|
|
/// Every tool, with its JSON schema. The same list serves the headless and the bridged backend.
|
|
pub fn tool_definitions() -> Vec<Value> {
|
|
vec![
|
|
tool(
|
|
"list_commands",
|
|
"List commands",
|
|
"The command catalog: id, label, toolbar tab/panel, shortcut, one-line parameter docs and whether the command is \
|
|
enabled right now. Ids are dotted and lower-case (solid.extrude, sketch.create, \
|
|
solid.fillet, sketch.inspect, timeline.rollback). Optional case-insensitive \
|
|
substring `filter` over id, label, tab and panel (not the parameter docs, so a command id \
|
|
finds that command rather than every command whose docs mention it).",
|
|
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
|
|
Effect::ReadOnly,
|
|
),
|
|
tool(
|
|
"execute",
|
|
"Execute a command",
|
|
"Run one command with JSON parameters, exactly as the toolbar, palette and scripts do. Never opens a dialog. \
|
|
Examples: sketch.create {plane: \"XY\"}; sketch.rectangle.two_point {p0: [0,0], p1: [40,30]}; sketch.circle.center \
|
|
{center: [20,15], radius: 5}; sketch.dimension {entities: [curve id], value: \"width\"}; sketch.finish {}; solid.extrude \
|
|
{distance: 20, operation: \"cut\"}; solid.fillet {edges: [[0,0,10]], radius: 3}; solid.box {length, \
|
|
width, height}. Lengths are mm; expressions may use parameters and units (\"width / 2\", \"30 deg\"). Returns \
|
|
the command's result; failures leave the design unchanged. Sketch commands that add geometry return \
|
|
created: {points, curves, constraints, dimensions}: the ids of what they added, stable and not reused \
|
|
after a delete (undo rewinds the counters with the design), usable right away \
|
|
in sketch.dimension, sketch.constraint.* and sketch.delete. Feature commands return the feature's id \
|
|
and name, every body (bodies) and the bodies the feature created, changed and removed (created_bodies, changed_bodies, removed_bodies).",
|
|
obj(
|
|
json!({
|
|
"command": string("Command id (see list_commands)"),
|
|
"params": {"type": "object", "description": "The command's parameters"},
|
|
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"},
|
|
}),
|
|
&["command"],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
tool(
|
|
"batch",
|
|
"Run several commands",
|
|
"Run commands in order, stopping at the first error. Each step is {command, params}. On an error the steps \
|
|
already run are undone (unless rollback: false), so the batch is all-or-nothing; the reply names the failing \
|
|
step (index from 0), its error, and every result before it. The usual way to build a part: sketch, draw, \
|
|
dimension, finish, extrude, fillet… in one call.",
|
|
obj(
|
|
json!({
|
|
"commands": {
|
|
"type": "array",
|
|
"description": "Steps: [{\"command\": id, \"params\": {...}}, ...]",
|
|
"items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]},
|
|
},
|
|
"rollback": {"type": "boolean", "description": "Undo completed steps when one fails (default true)"},
|
|
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"},
|
|
}),
|
|
&["commands"],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
tool(
|
|
"inspect_design",
|
|
"Inspect the design",
|
|
"The whole design: user parameters (expression, value, errors), the timeline (features with type, suppressed, \
|
|
rolled back, error, warning, timing), bodies (volume, area, centre of mass, bounding box, face/edge/vertex \
|
|
counts), sketches (curves with ids and coordinates, points, constraints, profiles, degrees of freedom, solve \
|
|
status), the active sketch, selection and undo depth.",
|
|
obj(
|
|
json!({
|
|
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
|
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
|
|
}),
|
|
&[],
|
|
),
|
|
Effect::ReadOnly,
|
|
),
|
|
tool(
|
|
"measure",
|
|
"Measure bodies",
|
|
"Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \
|
|
bodies, with totals. With `items` (one or two selections, as `execute` takes them: faces, edges, vertices, \
|
|
sketch curves, profiles): one item's length, area or position, a circle's, arc's or cylinder's `diameter` and \
|
|
`radius` (with `circumference`, or an arc's `arc_length` and `angle_deg`), a slot's `slot` sizes; two items' \
|
|
`min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis).",
|
|
obj(
|
|
json!({
|
|
"body": string("Body name (default: all bodies)"),
|
|
"items": {"type": "array", "items": {"type": "object"}, "description": "One or two selections to measure instead of bodies, e.g. {\"type\": \"face\", \"body\": \"Body1\", \"index\": 3, \"point\": [x,y,z]}"},
|
|
}),
|
|
&[],
|
|
),
|
|
Effect::ReadOnly,
|
|
),
|
|
tool(
|
|
"body_topology",
|
|
"List edges and faces",
|
|
"The edges (index, midpoint, length, endpoints) and faces (index, name, area, centroid, plane normal, a `point` that lies \
|
|
on the face, and for a cylindrical face its `cylinder`: origin, axis, radius) of a body. Use an edge midpoint as the \
|
|
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands.",
|
|
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
|
|
Effect::ReadOnly,
|
|
),
|
|
tool(
|
|
"set_parameter",
|
|
"Set a parameter",
|
|
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
|
|
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
|
|
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
|
|
obj(
|
|
json!({
|
|
"name": string("Parameter name"),
|
|
"value": {"type": ["number", "string"], "description": "Number or expression"},
|
|
"unit": string("mm, deg or \"\" (unitless); default from the expression"),
|
|
"comment": string("Comment shown in the parameters table"),
|
|
}),
|
|
&["name", "value"],
|
|
),
|
|
Effect::Edits,
|
|
),
|
|
tool(
|
|
"screenshot",
|
|
"Look at the design",
|
|
"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). \
|
|
`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(
|
|
"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)"},
|
|
"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"},
|
|
}),
|
|
&[],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
tool(
|
|
"export",
|
|
"Export",
|
|
"Export bodies to STEP (.step/.stp), 3MF (.3mf), binary STL (.stl), ASCII STL (format stla) or OBJ. The format \
|
|
comes from the extension unless given. URDF (.urdf) exports the whole assembly for robot simulators: a link per \
|
|
component placement with mass and inertia, joints from the assembly's joints, and STL meshes in metres written \
|
|
next to it (docs/urdf.md); RViz needs mesh_prefix such as package://robot/meshes/.",
|
|
obj(
|
|
json!({
|
|
"path": string("Output file"),
|
|
"format": {"type": "string", "enum": ["step", "3mf", "3mf-tagged", "stl", "stla", "obj", "urdf"]},
|
|
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all; not with urdf)"},
|
|
"mesh_prefix": string("urdf: put before each mesh file name (default: none, so relative to the .urdf)"),
|
|
"effort": {"type": "number", "description": "urdf: joint effort limit, N or N m (default 1000)"},
|
|
"velocity": {"type": "number", "description": "urdf: joint velocity limit, m/s or rad/s (default 10)"},
|
|
}),
|
|
&["path"],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
tool(
|
|
"crash_reports",
|
|
"Crash reports",
|
|
"SolidCraft's crash reports on this computer (the app's when connected to it). Without `id`: the list, newest \
|
|
first (id, kind, time, message, command, count, version, path, replayable). With `id`: the whole report: \
|
|
message, location, backtrace, app version and commit, system, the command that crashed and the recent ones, \
|
|
and design_path / replay_path when it can be replayed: `solidcraft-cli crashes replay <id>`, or \
|
|
`solidcraft-cli run <replay_path> --in <design_path>`. Reports never leave this computer.",
|
|
obj(
|
|
json!({
|
|
"id": string("A report id from the list"),
|
|
"limit": {"type": "integer", "description": "Reports to list (default 20)"},
|
|
}),
|
|
&[],
|
|
),
|
|
Effect::ReadOnly,
|
|
),
|
|
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
|
|
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
|
|
tool(
|
|
"new_design",
|
|
"New design",
|
|
"Start a new, empty design (unsaved changes to the current one are discarded).",
|
|
obj(json!({"name": string("Design name (default Untitled)")}), &[]),
|
|
Effect::Replaces,
|
|
),
|
|
tool(
|
|
"open",
|
|
"Open a design",
|
|
"Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
|
|
Import base feature in a component named after the file; an assembly's parts become nested components) or a \
|
|
mesh (.3mf/.stl/.obj/.glb/.gltf: mesh bodies in such a component; a glTF file's nodes become nested components and its \
|
|
materials appearances); the result reports the component (id, component_name). Replaces the current design; insert into it \
|
|
instead with the file.insert_step (STEP) or file.insert_mesh (mesh) commands. A STEP file from a system \
|
|
that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). glTF is Y up, \
|
|
so it is turned by default (`up: \"z\"` keeps its axes). `names` picks what the bodies are named after when the solids' own names say nothing useful.",
|
|
obj(
|
|
json!({
|
|
"path": string("Path to a .solidcraft, .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
|
|
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP and glTF only. z keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up. Default: z for STEP, y for glTF"},
|
|
"names": {"type": "string", "enum": ["solid", "product", "instance"], "description": "STEP only. What the bodies are named after: solid (default: the solid's own name, else the product's), product (the part's name) or instance (the assembly occurrence's name, else the product's)"},
|
|
}),
|
|
&["path"],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
tool(
|
|
"save",
|
|
"Save the design",
|
|
"Save the design to `path` or its current file. The default is a design container; a .json path selects plain JSON. Set format to container or json to override the path.",
|
|
obj(
|
|
json!({"path": string("Path to save to (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}),
|
|
&[],
|
|
),
|
|
Effect::Files,
|
|
),
|
|
]
|
|
}
|
|
|
|
fn type_matches(t: &str, v: &Value) -> bool {
|
|
match t {
|
|
"string" => v.is_string(),
|
|
"number" => v.is_number(),
|
|
"integer" => v.is_i64() || v.is_u64(),
|
|
"boolean" => v.is_boolean(),
|
|
"array" => v.is_array(),
|
|
"object" => v.is_object(),
|
|
"null" => v.is_null(),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
/// Validates `args` against the tool's schema (top level): unknown tool, missing required
|
|
/// arguments, unknown arguments, type and enum mismatches.
|
|
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
|
let defs = tool_definitions();
|
|
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
|
|
let schema = &def["inputSchema"];
|
|
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
|
|
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
|
if a.get(r).is_none_or(Value::is_null) {
|
|
return Err(format!("{name}: missing required argument `{r}`"));
|
|
}
|
|
}
|
|
let props = schema["properties"].as_object();
|
|
for (k, v) in a {
|
|
let Some(p) = props.and_then(|p| p.get(k)) else {
|
|
return Err(format!("{name}: unknown argument `{k}`"));
|
|
};
|
|
if v.is_null() {
|
|
continue;
|
|
}
|
|
let ok = match &p["type"] {
|
|
Value::String(t) => type_matches(t, v),
|
|
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
|
|
_ => true,
|
|
};
|
|
if !ok {
|
|
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
|
|
}
|
|
if let Some(allowed) = p["enum"].as_array()
|
|
&& !allowed.contains(v)
|
|
{
|
|
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A tool result: content blocks plus the error flag.
|
|
fn text_result(v: &Value, is_error: bool) -> Value {
|
|
let text = match v {
|
|
Value::String(s) => s.clone(),
|
|
v => serde_json::to_string_pretty(v).unwrap_or_default(),
|
|
};
|
|
let mut r = json!({"content": [{"type": "text", "text": text}]});
|
|
if is_error {
|
|
r["isError"] = json!(true);
|
|
}
|
|
r
|
|
}
|
|
|
|
/// Run a tool. Failures are tool results with `isError: true` (the model sees the message);
|
|
/// nothing here panics on hostile arguments.
|
|
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value {
|
|
let empty = Map::new();
|
|
let a = args.as_object().unwrap_or(&empty);
|
|
let r = match name {
|
|
"screenshot" => return screenshot(b, a),
|
|
"batch" => return batch(b, a),
|
|
_ => dispatch(b, name, a),
|
|
};
|
|
match r {
|
|
Ok(v) => text_result(&v, false),
|
|
Err(e) => text_result(&Value::String(e), true),
|
|
}
|
|
}
|
|
|
|
fn exec(b: &mut dyn Backend, command: &str, params: Value) -> Result<Value, String> {
|
|
b.call("engine.execute", json!({"command": command, "params": params}))
|
|
}
|
|
|
|
fn pick(a: &Map<String, Value>, keys: &[&str]) -> Value {
|
|
Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect())
|
|
}
|
|
|
|
fn str_arg<'a>(a: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
|
|
a.get(k).and_then(Value::as_str)
|
|
}
|
|
|
|
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
|
|
match name {
|
|
"list_commands" => {
|
|
let all = b.call("engine.commands", json!({}))?;
|
|
let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) };
|
|
let hit = |c: &Value| {
|
|
// Not "params": many docs name other commands (e.g. "usable in sketch.delete"), #269.
|
|
["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)))
|
|
};
|
|
Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default()))
|
|
}
|
|
"execute" => {
|
|
let id = str_arg(a, "command").ok_or("`command` is required")?;
|
|
let v = exec(b, id, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({})))?;
|
|
Ok(if a.get("verbose").and_then(Value::as_bool).unwrap_or(true) { v } else { compact(v) })
|
|
}
|
|
"inspect_design" => {
|
|
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
|
|
let mut d = b.call("document.inspect", json!({"measure": measure}))?;
|
|
if a.get("sketches").and_then(Value::as_bool).unwrap_or(true) {
|
|
let ids: Vec<Value> = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default();
|
|
let full: Vec<Value> = ids
|
|
.into_iter()
|
|
.map(|id| exec(b, "sketch.inspect", json!({"sketch": id.clone()})).unwrap_or_else(|e| json!({"id": id, "error": e})))
|
|
.collect();
|
|
d["sketches"] = Value::Array(full);
|
|
}
|
|
Ok(d)
|
|
}
|
|
"measure" => {
|
|
if let Some(items) = a.get("items") {
|
|
return exec(b, "inspect.measure", json!({ "items": items }));
|
|
}
|
|
let p = match str_arg(a, "body") {
|
|
Some(body) => json!({"bodies": [body]}),
|
|
None => json!({}),
|
|
};
|
|
exec(b, "inspect.measure", p)
|
|
}
|
|
"body_topology" => {
|
|
let p = pick(a, &["body"]);
|
|
let edges = exec(b, "model.edges", p.clone())?;
|
|
let faces = exec(b, "model.faces", p)?;
|
|
Ok(json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]}))
|
|
}
|
|
"set_parameter" => {
|
|
let mut p = pick(a, &["name", "unit", "comment"]);
|
|
p["expression"] = a.get("value").cloned().unwrap_or(Value::Null);
|
|
exec(b, "parameters.change", p)
|
|
}
|
|
"export" => exec(b, "file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])),
|
|
"crash_reports" => match a.get("id") {
|
|
Some(_) => exec(b, "diagnostics.crash_report", pick(a, &["id"])),
|
|
None => exec(b, "diagnostics.crash_reports", pick(a, &["limit"])),
|
|
},
|
|
"undo" => exec(b, "edit.undo", json!({})),
|
|
"redo" => exec(b, "edit.redo", json!({})),
|
|
"new_design" => exec(b, "file.new", pick(a, &["name"])),
|
|
"open" => exec(b, "doc.open", pick(a, &["path", "up", "names"])),
|
|
"save" => exec(b, "file.save", pick(a, &["path", "format"])),
|
|
other => Err(format!("unknown tool `{other}`")),
|
|
}
|
|
}
|
|
|
|
fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
|
|
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
|
}
|
|
|
|
/// How a failed batch goes back to where it started.
|
|
enum Undo {
|
|
/// An engine checkpoint: the design, active sketch and undo history exactly as they were.
|
|
Checkpoint(u64),
|
|
/// An app without checkpoints: undo as many steps as the batch added (wrong once the undo
|
|
/// history is full, as its depth no longer grows).
|
|
Depth(u64),
|
|
None,
|
|
}
|
|
|
|
impl Undo {
|
|
fn begin(b: &mut dyn Backend) -> Undo {
|
|
match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) {
|
|
Some(id) => Undo::Checkpoint(id),
|
|
None => undo_depth(b).map_or(Undo::None, Undo::Depth),
|
|
}
|
|
}
|
|
|
|
/// Go back; the number of steps undone.
|
|
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
|
|
match self {
|
|
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
|
|
Ok(_) => steps as u64,
|
|
Err(_) => 0,
|
|
},
|
|
Undo::Depth(before) => {
|
|
let after = undo_depth(b).unwrap_or(before);
|
|
let mut undone = 0u64;
|
|
for _ in before..after {
|
|
if exec(b, "edit.undo", json!({})).is_err() {
|
|
break;
|
|
}
|
|
undone += 1;
|
|
}
|
|
undone
|
|
}
|
|
Undo::None => 0,
|
|
}
|
|
}
|
|
|
|
/// The batch went through: forget the checkpoint.
|
|
fn finish(self, b: &mut dyn Backend) {
|
|
if let Undo::Checkpoint(id) = self {
|
|
let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `batch`: run steps in order, stop at the first failure and (by default) go back to the
|
|
/// design as it was before the batch.
|
|
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
|
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
|
|
return text_result(&json!("`commands` must be an array of {command, params}"), true);
|
|
};
|
|
if steps.len() > MAX_BATCH {
|
|
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
|
|
}
|
|
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
|
let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true);
|
|
// An app that runs batches itself answers the whole batch in one request, so one frame
|
|
// (#743). One too long for a control line goes step by step: the app would refuse it unrun.
|
|
// The margin covers the envelope and the `allow_paths` the backend adds.
|
|
let params = json!({"commands": steps, "rollback": rollback});
|
|
if params.to_string().len() + 64 * 1024 <= solidcraft_engine::MAX_LINE {
|
|
match b.call("engine.batch", params) {
|
|
Ok(r) => return batch_reply(&r, rollback, verbose),
|
|
// Older apps and the headless session, or the app wants the steps one by one.
|
|
Err(e) if e.starts_with("unknown method") || e.starts_with("engine.batch unavailable") => {}
|
|
// Never sent: no app to send it to.
|
|
Err(e) if e.starts_with(crate::backend::NOT_RUNNING) => return text_result(&Value::String(e), true),
|
|
// The app may have run it: sending the steps again would apply them twice.
|
|
Err(e) => {
|
|
let msg = format!("the app did not answer engine.batch ({e}); it may have run: inspect the design before sending it again");
|
|
return text_result(&Value::String(msg), true);
|
|
}
|
|
}
|
|
}
|
|
let start = if rollback { Undo::begin(b) } else { Undo::None };
|
|
let mut results = Vec::with_capacity(steps.len());
|
|
for (i, step) in steps.iter().enumerate() {
|
|
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
|
|
let r = match command {
|
|
Some(id) => exec(b, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))),
|
|
None => Err("missing `command`".to_string()),
|
|
};
|
|
match r {
|
|
Ok(v) => results.push(if verbose { v } else { compact(v) }),
|
|
Err(e) => {
|
|
let undone = start.rollback(b, i);
|
|
return text_result(
|
|
&json!({
|
|
"ok": false,
|
|
"completed": i,
|
|
"failed": {"index": i, "command": command, "error": e},
|
|
"rolled_back": rollback,
|
|
"undone": undone,
|
|
"results": results,
|
|
}),
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
start.finish(b);
|
|
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
|
|
}
|
|
|
|
/// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it.
|
|
fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value {
|
|
let results: Vec<Value> =
|
|
r["results"].as_array().cloned().unwrap_or_default().into_iter().map(|v| if verbose { v } else { compact(v) }).collect();
|
|
match r.get("failed").filter(|f| !f.is_null()) {
|
|
None => text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false),
|
|
Some(failed) => text_result(
|
|
&json!({
|
|
"ok": false,
|
|
"completed": r["completed"],
|
|
"failed": failed,
|
|
"rolled_back": rollback,
|
|
"undone": r["undone"],
|
|
"results": results,
|
|
}),
|
|
true,
|
|
),
|
|
}
|
|
}
|
|
|
|
/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a
|
|
/// feature's full body list (its created, changed and removed bodies stay), and the sketch ids
|
|
/// a sketch command repeats outside `created`.
|
|
fn compact(mut v: Value) -> Value {
|
|
let Some(o) = v.as_object_mut() else { return v };
|
|
if o.contains_key("created_bodies") {
|
|
o.remove("bodies");
|
|
}
|
|
if let Some(created) = o.get("created").cloned() {
|
|
for k in ["points", "curves", "constraints", "dimensions"] {
|
|
if o.get(k).is_some_and(|x| created.get(k) == Some(x)) {
|
|
o.remove(k);
|
|
}
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
static SHOT: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// `screenshot`: render or capture to a file, return it as an MCP image content block.
|
|
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
|
match capture(b, a) {
|
|
Ok((png, info)) => json!({"content": [
|
|
{"type": "image", "data": base64(&png), "mimeType": "image/png"},
|
|
{"type": "text", "text": info.to_string()},
|
|
]}),
|
|
Err(e) => text_result(&Value::String(e), true),
|
|
}
|
|
}
|
|
|
|
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()),
|
|
(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(|| {
|
|
let n = SHOT.fetch_add(1, Ordering::Relaxed);
|
|
let dir = crate::scratch_dir().unwrap_or_else(vfs::temp_dir);
|
|
dir.join(format!("shot-{n}.png")).to_string_lossy().to_string()
|
|
});
|
|
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| {
|
|
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 asked_window = str_arg(a, "source") == Some("window");
|
|
let mut fallback = None;
|
|
let (info, source) = if window {
|
|
match b.call("ui.screenshot", json!({"path": path})) {
|
|
Ok(v) => (v, "window"),
|
|
// Asked for the window: say why there is none, so the caller can show it or ask for
|
|
// a model render (#1677).
|
|
Err(e) if asked_window => {
|
|
return Err(format!("screenshot: the window gave no picture ({e}); show the window, or use source \"model\""));
|
|
}
|
|
// No presented frame (minimised or hidden window): a model render, and the reply says so.
|
|
Err(e) => {
|
|
fallback = Some(e);
|
|
(render(b)?, "model")
|
|
}
|
|
}
|
|
} else {
|
|
(render(b)?, "model")
|
|
};
|
|
let read = vfs::read(&path, MAX_IMAGE_BYTES).map_err(|e| format!("{path}: {e} (is the app on another machine?)"));
|
|
if keep.is_none() {
|
|
let _ = vfs::remove(&path);
|
|
}
|
|
let png = read?;
|
|
let (w, h) = match source {
|
|
"model" => (json!(size("width", 960)), json!(size("height", 600))),
|
|
_ => (info.get("width").cloned().unwrap_or(Value::Null), info.get("height").cloned().unwrap_or(Value::Null)),
|
|
};
|
|
let bytes = png.len();
|
|
let mut reply = json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes});
|
|
if let Some(why) = fallback {
|
|
reply["fallback"] = json!(format!("the window gave no picture ({why}); this is a model render"));
|
|
}
|
|
// What drew a model render (gpu, gpu-software or cpu), and the warning a CPU render carries.
|
|
for k in ["renderer", "warning"] {
|
|
if let Some(v) = info.get(k).filter(|v| !v.is_null()) {
|
|
reply[k] = v.clone();
|
|
}
|
|
}
|
|
Ok((png, reply))
|
|
}
|
|
|
|
/// Standard base64 with padding (RFC 4648).
|
|
pub(crate) fn base64(data: &[u8]) -> String {
|
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for chunk in data.chunks(3) {
|
|
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
|
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
|
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
|
|
if i <= chunk.len() {
|
|
out.push(char::from(T[((n >> shift) & 63) as usize]));
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|