use crate::host::{Backend, HostConfig};
use crate::session::Session;
use crate::tools::app::{after_wait, current, wait_idle, SessionSlot};
use crate::tools::{object_schema, Annotations, ToolImage, ToolOutput, ToolSpec};
use crate::{image, schema, validate};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Arc;
pub struct ServerContext {
pub slot: SessionSlot,
pub session_root: PathBuf,
pub backend: Backend,
pub on_tools_changed: Arc<dyn Fn() + Send + Sync>,
}
fn s(v: &Value, k: &str) -> Option<String> {
v.get(k).and_then(Value::as_str).map(str::to_string)
}
fn f(v: &Value, k: &str, d: f32) -> f32 {
v.get(k).and_then(Value::as_f64).map(|x| x as f32).unwrap_or(d)
}
fn b(v: &Value, k: &str, d: bool) -> bool {
v.get(k).and_then(Value::as_bool).unwrap_or(d)
}
fn u(v: &Value, k: &str, d: u64) -> u64 {
v.get(k).and_then(Value::as_u64).unwrap_or(d)
}
pub fn session_tools(cx: Arc<ServerContext>) -> Vec<ToolSpec> {
let start_cx = cx.clone();
let stop_cx = cx.clone();
let info_cx = cx.clone();
let rec_cx = cx.clone();
let script_cx = cx.clone();
vec![
ToolSpec::new(
"session_start",
"session",
session_start_doc(&cx.backend),
session_start_schema(&cx.backend),
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let cx = start_cx.clone();
Box::pin(async move {
let session = match &cx.backend {
Backend::Attached { .. } => {
let session = match cx.slot.read().await.clone() {
Some(s) => s,
None => attach_session(&cx, b(&args, "record", true)).await?,
};
session.set_recording(b(&args, "record", true));
session
}
Backend::Spawn { name, spawn } => {
if cx.slot.read().await.is_some() {
return Err("a session is already live; call session_stop first".into());
}
let backend = s(&args, "backend").unwrap_or_else(|| name.to_string());
if backend != *name {
return Err(format!("backend `{backend}` is not available here; this server hosts `{name}`"));
}
let root = cx.session_root.clone();
std::fs::create_dir_all(&root).map_err(|e| format!("session root {}: {e}", root.display()))?;
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
let store_dir = match s(&args, "store_root") {
Some(p) => PathBuf::from(p),
None => root.join(format!("store-{stamp}")),
};
let cfg = HostConfig {
width: f(&args, "width", 1400.0),
height: f(&args, "height", 960.0),
ppp: f(&args, "ppp", 1.0),
seed: b(&args, "seed", false),
store_dir,
};
let spawn = spawn.clone();
let host = tokio::task::spawn_blocking(move || spawn(cfg)).await.map_err(|e| e.to_string())??;
Arc::new(Session::new(&root, host, b(&args, "record", true))?)
}
};
if let Some(path) = s(&args, "document") {
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string());
session.host.call_ok("doc_load", json!({ "json": text, "name": name })).await?;
}
let describe = session.host.call_ok("describe_commands", json!({})).await?.result.unwrap_or(Value::Null);
let was_live = cx.slot.read().await.is_some();
*cx.slot.write().await = Some(session.clone());
let after = after_wait(&session, 60_000).await?;
if !was_live {
(cx.on_tools_changed)();
}
let mut info = serde_json::to_value(session.info()).map_err(|e| e.to_string())?;
info["after"] = after;
info["commands"] = json!(describe.as_array().map(|a| a.len()).unwrap_or(0));
Ok(ToolOutput::json(info))
})
},
),
ToolSpec::new(
"session_stop",
"session",
if cx.backend.is_attached() {
"Stop recording and detach from the app; the app stays open and its tools stay available."
} else {
"Stop the live session and its app; flushes the recording."
},
object_schema(json!({}), &[]),
Annotations { read_only: false, destructive: !cx.backend.is_attached(), idempotent: true, waits: false },
move |_args| {
let cx = stop_cx.clone();
Box::pin(async move {
if cx.backend.is_attached() {
let session = current(&cx.slot).await?;
session.set_recording(false);
return Ok(ToolOutput::json(serde_json::to_value(session.info()).map_err(|e| e.to_string())?));
}
let Some(session) = cx.slot.write().await.take() else {
return Err("no live session".into());
};
let info = session.info();
match Arc::try_unwrap(session) {
Ok(s) => tokio::task::spawn_blocking(move || s.host.stop()).await.map_err(|e| e.to_string())?,
Err(_) => return Err("session still in use by another call".into()),
}
(cx.on_tools_changed)();
Ok(ToolOutput::json(serde_json::to_value(info).map_err(|e| e.to_string())?))
})
},
),
ToolSpec::new(
"session_info",
"session",
"The live session: id, directory, host, uptime, shots taken, recording state, and the current frame_info.",
object_schema(json!({}), &[]),
Annotations::READ,
move |_args| {
let cx = info_cx.clone();
Box::pin(async move {
let session = current(&cx.slot).await?;
let mut v = serde_json::to_value(session.info()).map_err(|e| e.to_string())?;
v["frame_info"] = session.host.call_ok("frame_info", json!({})).await?.result.unwrap_or(Value::Null);
v["pointer"] = session.host.call_ok("pointer_state", json!({})).await?.result.unwrap_or(Value::Null);
Ok(ToolOutput::json(v))
})
},
),
ToolSpec::new(
"session_record",
"session",
"Turn call recording on or off.",
object_schema(json!({ "on": { "type": "boolean" } }), &["on"]),
Annotations { read_only: false, destructive: false, idempotent: true, waits: false },
move |args| {
let cx = rec_cx.clone();
Box::pin(async move {
let session = current(&cx.slot).await?;
session.set_recording(b(&args, "on", true));
Ok(ToolOutput::json(json!({ "recording": b(&args, "on", true) })))
})
},
),
ToolSpec::new(
"session_script",
"session",
"The recorded calls of this session as a test-mcp script (expectations left for the author). `since` skips the first N calls.",
object_schema(json!({ "name": { "type": "string", "default": "recorded" }, "since": { "type": "integer", "default": 0 } }), &[]),
Annotations::READ,
move |args| {
let cx = script_cx.clone();
Box::pin(async move {
let session = current(&cx.slot).await?;
let name = s(&args, "name").unwrap_or_else(|| "recorded".into());
Ok(ToolOutput::json(json!({ "script": session.script(&name, u(&args, "since", 0) as usize) })))
})
},
),
]
}
fn session_start_doc(backend: &Backend) -> String {
match backend {
Backend::Attached { .. } => format!(
"Attach to the running app (backend `{}`): the window you see is the session, with its own document store. \
document: a .BREP.json path to open. record: log every call for session_script. \
Returns the session id, directory and host info. Calling it again keeps the session and only applies `record` and `document`.",
backend.name()
),
Backend::Spawn { name, .. } => format!(
"Start an app session. backend: {name} (no display needed). width/height in egui points (1400×960), ppp (1). \
seed: start on the seed model (default false → empty document). document: a .BREP.json path to open. \
record: log every call for session_script. Returns the session id, directory and host info; \
the tool list is regenerated from the app's registries."
),
}
}
fn session_start_schema(backend: &Backend) -> Value {
match backend {
Backend::Attached { .. } => object_schema(json!({
"document": { "type": "string" },
"record": { "type": "boolean", "default": true }
}), &[]),
Backend::Spawn { name, .. } => object_schema(json!({
"backend": { "type": "string", "enum": [name], "default": name },
"width": { "type": "number", "default": 1400 },
"height": { "type": "number", "default": 960 },
"ppp": { "type": "number", "default": 1 },
"seed": { "type": "boolean", "default": false },
"document": { "type": "string" },
"record": { "type": "boolean", "default": true },
"store_root": { "type": "string", "description": "a persistent store directory (recovery tests); default: a fresh one under the session dir" }
}), &[]),
}
}
pub async fn attach_session(cx: &ServerContext, record: bool) -> Result<Arc<Session>, String> {
let mut host = cx.backend.attached_handle().ok_or("this server spawns its sessions; use session_start")?;
let frame = host.call_ok("frame_info", json!({})).await?.result.unwrap_or(Value::Null);
if let Some(surface) = frame["surface"].as_array() {
host.info.width = surface.first().and_then(Value::as_f64).unwrap_or(host.info.width as f64) as f32;
host.info.height = surface.get(1).and_then(Value::as_f64).unwrap_or(host.info.height as f64) as f32;
}
if let Some(ppp) = frame["ppp"].as_f64() {
host.info.ppp = ppp as f32;
}
let root = cx.session_root.clone();
std::fs::create_dir_all(&root).map_err(|e| format!("session root {}: {e}", root.display()))?;
let session = Arc::new(Session::new(&root, host, record)?);
*cx.slot.write().await = Some(session.clone());
Ok(session)
}
async fn seq(session: &Session, steps: &[(&str, Value)]) -> Result<(), String> {
for (cmd, args) in steps {
session.host.call_ok(cmd, args.clone()).await?;
}
Ok(())
}
pub fn pointer_tools(slot: SessionSlot) -> Vec<ToolSpec> {
let click_slot = slot.clone();
let drag_slot = slot.clone();
let hotkey_slot = slot.clone();
let type_slot = slot.clone();
let widget_slot = slot.clone();
let scroll_slot = slot.clone();
let wait_slot = slot.clone();
vec![
ToolSpec::new(
"click",
"pointer",
"Move to (x, y) in egui points and click: down then up, one frame each. button primary|secondary|middle; count 2 for a double click; modifiers {ctrl, shift, alt, command} held for the click.",
object_schema(json!({
"x": { "type": "number" }, "y": { "type": "number" },
"button": { "type": "string", "enum": ["primary", "secondary", "middle"], "default": "primary" },
"count": { "type": "integer", "default": 1, "minimum": 1, "maximum": 3 },
"modifiers": { "type": "object", "properties": { "ctrl": {"type":"boolean"}, "shift": {"type":"boolean"}, "alt": {"type":"boolean"}, "command": {"type":"boolean"} } }
}), &["x", "y"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: false },
move |args| {
let slot = click_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let button = s(&args, "button").unwrap_or_else(|| "primary".into());
let mods = args.get("modifiers").cloned().unwrap_or(json!({}));
let mut steps = vec![("modifiers_set", mods), ("pointer_move", json!({ "x": f(&args, "x", 0.0), "y": f(&args, "y", 0.0) }))];
for _ in 0..u(&args, "count", 1).clamp(1, 3) {
steps.push(("pointer_down", json!({ "button": button })));
steps.push(("pointer_up", json!({ "button": button })));
}
steps.push(("modifiers_set", json!({})));
seq(&session, &steps).await?;
session.record("click", &args, true, json!({}));
Ok(ToolOutput::json(json!({ "clicked": [f(&args, "x", 0.0), f(&args, "y", 0.0)] })))
})
},
),
ToolSpec::new(
"drag",
"pointer",
"Press at `from`, move to `to` over `steps` frames (12), release. button primary|secondary|middle (middle orbits/pans the viewport). Points in egui points.",
object_schema(json!({
"from": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"to": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"button": { "type": "string", "enum": ["primary", "secondary", "middle"], "default": "primary" },
"steps": { "type": "integer", "default": 12, "minimum": 1 },
"modifiers": { "type": "object" }
}), &["from", "to"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: false },
move |args| {
let slot = drag_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let pt = |k: &str| -> Result<(f32, f32), String> {
let a = args.get(k).and_then(Value::as_array).ok_or_else(|| format!("`{k}` must be [x, y]"))?;
Ok((a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32))
};
let (x0, y0) = pt("from")?;
let (x1, y1) = pt("to")?;
let n = u(&args, "steps", 12).max(1) as usize;
let button = s(&args, "button").unwrap_or_else(|| "primary".into());
let mut steps = vec![
("modifiers_set", args.get("modifiers").cloned().unwrap_or(json!({}))),
("pointer_move", json!({ "x": x0, "y": y0 })),
("pointer_down", json!({ "button": button })),
];
for i in 1..=n {
let t = i as f32 / n as f32;
steps.push(("pointer_move", json!({ "x": x0 + (x1 - x0) * t, "y": y0 + (y1 - y0) * t })));
}
steps.push(("pointer_up", json!({ "button": button })));
steps.push(("modifiers_set", json!({})));
seq(&session, &steps).await?;
session.record("drag", &args, true, json!({}));
Ok(ToolOutput::json(json!({ "from": [x0, y0], "to": [x1, y1], "steps": n })))
})
},
),
ToolSpec::new(
"hotkey",
"keyboard",
"Press a key combination such as `ctrl+z`, `ctrl+shift+z`, `Escape`, `Delete`: modifiers down, key press and release, modifiers up.",
object_schema(json!({ "combo": { "type": "string" } }), &["combo"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: false },
move |args| {
let slot = hotkey_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let combo = s(&args, "combo").ok_or("missing `combo`")?;
let mut mods = json!({});
let mut key = String::new();
for part in combo.split('+') {
match part.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" => mods["ctrl"] = json!(true),
"shift" => mods["shift"] = json!(true),
"alt" => mods["alt"] = json!(true),
"cmd" | "command" | "meta" => mods["command"] = json!(true),
other => key = if other.len() == 1 { other.to_ascii_uppercase() } else { part.trim().to_string() },
}
}
if key.is_empty() {
return Err("combo has no key".into());
}
seq(&session, &[
("modifiers_set", mods),
("key", json!({ "key": key, "pressed": true })),
("key", json!({ "key": key, "pressed": false })),
("modifiers_set", json!({})),
]).await?;
session.record("hotkey", &args, true, json!({}));
Ok(ToolOutput::json(json!({ "key": key })))
})
},
),
ToolSpec::new(
"type_text",
"keyboard",
"Type text into the focused widget (click a field first). `enter: true` presses Enter afterwards to commit.",
object_schema(json!({ "text": { "type": "string" }, "enter": { "type": "boolean", "default": false } }), &["text"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: false },
move |args| {
let slot = type_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let text = s(&args, "text").unwrap_or_default();
let mut steps = vec![("text", json!({ "text": text }))];
if b(&args, "enter", false) {
steps.push(("key", json!({ "key": "Enter", "pressed": true })));
steps.push(("key", json!({ "key": "Enter", "pressed": false })));
}
seq(&session, &steps).await?;
session.record("type_text", &args, true, json!({}));
Ok(ToolOutput::json(json!({})))
})
},
),
ToolSpec::new(
"click_widget",
"widgets",
"Click a published widget by its `panel/key` (see hit_rects / hit_key_docs), scrolling its pane so the widget is inside `panel:clip` first. Errors if the key is not published this frame.",
object_schema(json!({ "key": { "type": "string" }, "button": { "type": "string", "enum": ["primary", "secondary", "middle"], "default": "primary" }, "count": { "type": "integer", "default": 1 } }), &["key"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: false },
move |args| {
let slot = widget_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let key = s(&args, "key").ok_or("missing `key`")?;
let rect = scroll_into_view(&session, &key).await?;
let (cx, cy) = (rect[0] + rect[2] / 2.0, rect[1] + rect[3] / 2.0);
let button = s(&args, "button").unwrap_or_else(|| "primary".into());
let mut steps = vec![("pointer_move", json!({ "x": cx, "y": cy }))];
for _ in 0..u(&args, "count", 1).clamp(1, 3) {
steps.push(("pointer_down", json!({ "button": button })));
steps.push(("pointer_up", json!({ "button": button })));
}
seq(&session, &steps).await?;
session.record("click_widget", &args, true, json!({ "at": [cx, cy] }));
Ok(ToolOutput::json(json!({ "key": key, "clicked": [cx, cy], "rect": rect })))
})
},
),
ToolSpec::new(
"scroll_into_view",
"widgets",
"Scroll a pane until the widget `panel/key` is inside its `panel:clip` rect; returns the rect.",
object_schema(json!({ "key": { "type": "string" } }), &["key"]),
Annotations { read_only: false, destructive: false, idempotent: true, waits: false },
move |args| {
let slot = scroll_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let key = s(&args, "key").ok_or("missing `key`")?;
let rect = scroll_into_view(&session, &key).await?;
Ok(ToolOutput::json(json!({ "key": key, "rect": rect })))
})
},
),
ToolSpec::new(
"wait_idle",
"capture",
"Wait until the history runner and every background query are idle (plus settle frames), or the timeout. Returns frame_info.",
object_schema(json!({ "timeout_ms": { "type": "integer", "default": 60000 }, "settle_frames": { "type": "integer", "default": 2 } }), &[]),
Annotations::READ,
move |args| {
let slot = wait_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let info = wait_idle(&session, u(&args, "timeout_ms", 60_000), u(&args, "settle_frames", 2) as u32).await?;
Ok(ToolOutput::json(info))
})
},
),
]
}
async fn scroll_into_view(session: &Session, key: &str) -> Result<[f32; 4], String> {
let (panel, _) = key.split_once('/').ok_or("key must be `panel/key`")?;
const SLACK: f32 = 1.0;
for _ in 0..24 {
let rects = session.host.call_ok("hit_rects", json!({ "prefix": format!("{panel}/") })).await?.result.unwrap_or(Value::Null);
let rect = rects["rects"][key].as_array().map(|a| {
[a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32, a[3].as_f64().unwrap_or(0.0) as f32]
});
let Some(rect) = rect else {
return Err(format!("no widget `{key}` is published this frame (see hit_rects)"));
};
let clip = rects["rects"][format!("{panel}/panel:clip")].as_array().map(|a| {
[a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32, a[3].as_f64().unwrap_or(0.0) as f32]
});
let Some(clip) = clip else { return Ok(rect) };
let inside = rect[1] >= clip[1] - SLACK && rect[1] + rect[3] <= clip[1] + clip[3] + SLACK;
if inside {
return Ok(rect);
}
let (cx, cy) = (clip[0] + clip[2] / 2.0, clip[1] + clip[3] / 2.0);
let dy = if rect[1] < clip[1] { 120.0 } else { -120.0 };
seq(session, &[("pointer_move", json!({ "x": cx, "y": cy })), ("wheel", json!({ "dx": 0, "dy": dy }))]).await?;
}
Err(format!("could not scroll `{key}` into view"))
}
pub fn key_documented(docs: &Value, panel: &str, key: &str) -> bool {
docs.as_array().into_iter().flatten().any(|d| {
d["panel"].as_str() == Some(panel) && {
let prefix = d["prefix"].as_str().unwrap_or("");
prefix.is_empty()
|| key == prefix
|| (prefix.ends_with(':') && key.starts_with(prefix))
|| (prefix.starts_with(':') && key.contains(prefix))
}
})
}
pub fn registry_tools(slot: SessionSlot) -> Vec<ToolSpec> {
vec![ToolSpec::new(
"hit_keys_check",
"widgets",
"Every widget key the app publishes this frame that no panel has documented (the hit-key registry gate). An empty list is the pass condition.",
object_schema(json!({}), &[]),
Annotations::READ,
move |_args| {
let slot = slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let rects = session.host.call_ok("hit_rects", json!({})).await?.result.unwrap_or(Value::Null);
let docs = session.host.call_ok("hit_key_docs", json!({})).await?.result.unwrap_or(Value::Null);
let docs = docs["keys"].clone();
let mut undocumented = Vec::new();
let mut checked = 0;
if let Some(map) = rects["rects"].as_object() {
for full in map.keys() {
checked += 1;
if let Some((panel, key)) = full.split_once('/') {
if !key_documented(&docs, panel, key) {
undocumented.push(full.clone());
}
}
}
}
Ok(ToolOutput::json(json!({ "checked": checked, "undocumented": undocumented })))
})
},
)]
}
struct Presentation {
settings: Option<Value>,
selection: Option<Value>,
}
impl Presentation {
async fn apply(session: &Session, args: &Value) -> Result<Self, String> {
let mut loan = Presentation { settings: None, selection: None };
if let Some(patch) = args.get("settings").and_then(Value::as_object) {
if !patch.is_empty() {
let live = session.host.call_ok("settings_get", json!({})).await?.result.unwrap_or(Value::Null);
let live = &live["settings"];
let restore: serde_json::Map<String, Value> = patch
.keys()
.filter_map(|k| live.get(k).map(|v| (k.clone(), v.clone())))
.collect();
session.host.call_ok("settings_set", json!({ "patch": patch })).await?;
loan.settings = Some(Value::Object(restore));
}
}
if b(args, "clear_selection", false) {
let live = session.host.call_ok("selection", json!({})).await?.result.unwrap_or(Value::Null);
session.host.call_ok("select_clear", json!({})).await?;
loan.selection = Some(live["selection"].clone());
}
Ok(loan)
}
async fn restore(&self, session: &Session) {
if let Some(patch) = &self.settings {
let _ = session.host.call("settings_set", json!({ "patch": patch })).await;
}
if let Some(sel) = &self.selection {
let _ = session
.host
.call(
"selection_set",
json!({
"solids": sel["solids"].clone(),
"faces": sel["faces"].clone(),
"edges": sel["edges"].clone(),
"datums": sel["datums"].clone(),
}),
)
.await;
}
}
}
pub fn capture_tools(slot: SessionSlot) -> Vec<ToolSpec> {
vec![ToolSpec::new(
"screenshot",
"capture",
"Capture the composited frame (panels and 3D view). region: full (default), viewport, or {x,y,w,h} in egui points. The image is returned inline, downscaled to max_width (1024), and the full-resolution PNG is written under the session's shots directory. cursor: draw the virtual pointer. \
For a clean presentation render pass `settings` and/or `clear_selection`: they apply for THIS capture only and are put back afterwards, so a screenshot never leaves the session looking different than it found it — e.g. `settings: {showVertices: false, showEdges: false}` for a shaded render.",
object_schema(json!({
"region": { "oneOf": [{ "type": "string", "enum": ["full", "viewport"] }, { "type": "object", "properties": { "x": {"type":"number"}, "y": {"type":"number"}, "w": {"type":"number"}, "h": {"type":"number"} }, "required": ["x","y","w","h"] }], "default": "full" },
"max_width": { "type": "integer", "default": 1024 },
"cursor": { "type": "boolean", "default": true },
"save_as": { "type": "string", "description": "also copy the full-resolution PNG to this path" },
"settings": { "type": "object", "description": "a settings patch (any `settings_get` key) applied for this capture only and reverted afterwards — showFaces / showEdges / showVertices / wireframe / background / flatShading are the presentation knobs" },
"clear_selection": { "type": "boolean", "default": false, "description": "drop the selection highlight for this capture, then put the selection back (position-keyed vertex selections do not survive the round trip)" }
}), &[]),
Annotations::READ,
move |args| {
let slot = slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let region = args.get("region").cloned().unwrap_or(json!("full"));
let presentation = Presentation::apply(&session, &args).await?;
let shot = session.host.screenshot(region.clone()).await;
presentation.restore(&session).await;
let (mut info, png) = shot?;
let mut img = image::decode_png(&png)?;
if b(&args, "cursor", true) {
if let Ok(r) = session.host.call_ok("pointer_state", json!({})).await {
if let Some(pos) = r.result.as_ref().and_then(|v| v["pos"].as_array()) {
let ppp = info["ppp"].as_f64().unwrap_or(1.0);
let ox = info["region"][0].as_f64().unwrap_or(0.0);
let oy = info["region"][1].as_f64().unwrap_or(0.0);
let x = ((pos[0].as_f64().unwrap_or(0.0) - ox) * ppp).round() as i64;
let y = ((pos[1].as_f64().unwrap_or(0.0) - oy) * ppp).round() as i64;
let pressed = r.result.as_ref().and_then(|v| v["buttons"].as_array()).map(|b| !b.is_empty()).unwrap_or(false);
image::draw_cursor(&mut img, x, y, pressed);
}
}
}
let full_png = image::encode_png(&img)?;
let (n, path) = session.next_shot();
std::fs::write(&path, &full_png).map_err(|e| format!("{}: {e}", path.display()))?;
if let Some(p) = s(&args, "save_as") {
std::fs::write(&p, &full_png).map_err(|e| format!("{p}: {e}"))?;
}
let inline = image::scale_to_width(&img, u(&args, "max_width", 1024) as u32);
let inline_png = image::encode_png(&inline)?;
info["shot"] = json!(n);
info["path"] = json!(path.display().to_string());
info["inline_size"] = json!([inline.width(), inline.height()]);
info["flat"] = json!(image::is_flat(&img));
session.record("screenshot", &args, true, json!({ "shot": n }));
Ok(ToolOutput { json: info, images: vec![ToolImage { png: inline_png, mime: "image/png" }] })
})
},
)]
}
pub fn document_tools(slot: SessionSlot) -> Vec<ToolSpec> {
let open_slot = slot.clone();
let save_slot = slot.clone();
let import_slot = slot.clone();
let export_slot = slot.clone();
vec![
ToolSpec::new(
"document_open",
"document",
"Open a .BREP.json file from disk in a new tab and run it (the server reads the file; the app sees only its content).",
object_schema(json!({ "path": { "type": "string" }, "timeout_ms": { "type": "integer", "default": 60000 } }), &["path"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let slot = open_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let path = s(&args, "path").ok_or("missing `path`")?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string());
let mut r = session.host.call_ok("doc_load", json!({ "json": text, "name": name })).await?.result.unwrap_or(Value::Null);
r["after"] = after_wait(&session, u(&args, "timeout_ms", 60_000)).await?;
session.record("document_open", &args, true, json!({}));
Ok(ToolOutput::json(r))
})
},
),
ToolSpec::new(
"document_save",
"document",
"Write the active document as .BREP.json to `path` and mark it clean. Refuses to overwrite unless overwrite is true.",
object_schema(json!({ "path": { "type": "string" }, "overwrite": { "type": "boolean", "default": false } }), &["path"]),
Annotations { read_only: false, destructive: false, idempotent: true, waits: false },
move |args| {
let slot = save_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let path = s(&args, "path").ok_or("missing `path`")?;
if std::path::Path::new(&path).exists() && !b(&args, "overwrite", false) {
return Err(format!("{path} exists; pass overwrite: true"));
}
let doc = session.host.call_ok("doc_json", json!({})).await?.result.unwrap_or(Value::Null);
let text = serde_json::to_string_pretty(&doc["document"]).map_err(|e| e.to_string())?;
if let Some(parent) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&path, &text).map_err(|e| format!("{path}: {e}"))?;
session.host.call_ok("doc_mark_clean", json!({})).await?;
session.record("document_save", &args, true, json!({}));
Ok(ToolOutput::json(json!({ "path": path, "bytes": text.len() })))
})
},
),
ToolSpec::new(
"document_import",
"document",
"Import a STEP / IGES / STL / OBJ file from disk as an Import 3D Model feature (format by extension unless given).",
object_schema(json!({ "path": { "type": "string" }, "format": { "type": "string", "enum": ["step", "iges", "stl", "obj"] }, "timeout_ms": { "type": "integer", "default": 120000 } }), &["path"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let slot = import_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let path = s(&args, "path").ok_or("missing `path`")?;
let ext = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let format = s(&args, "format").unwrap_or(match ext.as_str() {
"step" | "stp" => "step".into(),
"iges" | "igs" => "iges".into(),
"stl" => "stl".into(),
"obj" => "obj".into(),
_ => return Err(format!("cannot tell the format of `{path}`; pass `format`")),
});
let bytes = std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?;
let b64 = base64_encode(&bytes);
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut r = session.host.call_ok("doc_import", json!({ "format": format, "name": name, "base64": b64 })).await?.result.unwrap_or(Value::Null);
r["after"] = after_wait(&session, u(&args, "timeout_ms", 120_000)).await?;
session.record("document_import", &args, true, json!({}));
Ok(ToolOutput::json(r))
})
},
),
ToolSpec::new(
"document_export",
"document",
"Export the active document to a file: brep (native JSON), step, stl, iges, or the sheet-metal flat pattern as dxf / svg.",
object_schema(json!({ "format": { "type": "string", "enum": ["brep", "step", "stl", "iges", "dxf", "svg"] }, "path": { "type": "string" } }), &["format", "path"]),
Annotations { read_only: false, destructive: false, idempotent: true, waits: false },
move |args| {
let slot = export_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let path = s(&args, "path").ok_or("missing `path`")?;
let format = s(&args, "format").ok_or("missing `format`")?;
let r = session.host.call_ok("doc_export", json!({ "format": format })).await?.result.unwrap_or(Value::Null);
let text = r["text"].as_str().unwrap_or("");
std::fs::write(&path, text).map_err(|e| format!("{path}: {e}"))?;
session.record("document_export", &args, true, json!({}));
Ok(ToolOutput::json(json!({ "path": path, "format": format, "bytes": text.len() })))
})
},
),
]
}
fn feature_item_schema() -> Value {
json!({
"type": { "type": "string", "description": "catalogue type or shortName, e.g. E, P.CU, B, F" },
"params": { "type": "object", "default": {}, "description": "partial inputParams; every other key is seeded from the schema default" },
"id": { "type": "string", "description": "the feature's id; also honoured as `params.id`, and minted from the shortName when neither is given" },
"persistent_data": { "type": "object", "description": "persistentData (e.g. a sketch block)" }
})
}
async fn seed_feature(
session: &Session,
item: &Value,
known: Option<&std::collections::HashSet<String>>,
) -> Result<(String, Value, Vec<String>), String> {
let ty = s(item, "type").ok_or("missing `type`")?;
let entry = schema::entry(&ty).ok_or_else(|| format!("unknown feature type `{ty}` (see feature_catalogue)"))?;
let id_ = schema::identity(&entry);
let mut params = validate::merge_params(&schema::defaults(&id_.feature_type), item.get("params").unwrap_or(&json!({})));
let id = match s(item, "id").or_else(|| s(item.get("params").unwrap_or(&json!({})), "id")) {
Some(id) => id,
None => {
let r = session.host.call_ok("next_feature_id", json!({ "base": id_.short_name })).await?;
r.result.and_then(|v| v["id"].as_str().map(str::to_string)).ok_or("next_feature_id gave no id")?
}
};
params["id"] = json!(id);
let v = validate::validate(&id_.feature_type, ¶ms, known);
if !v.ok() {
return Err(format!("invalid parameters for {} `{id}`: {}", id_.long_name, v.errors.join("; ")));
}
let mut feature = json!({ "type": id_.feature_type, "inputParams": params });
if let Some(pd) = item.get("persistent_data") {
feature["persistentData"] = pd.clone();
}
Ok((id, feature, v.warnings))
}
pub fn feature_tools(slot: SessionSlot) -> Vec<ToolSpec> {
let add_slot = slot.clone();
let many_slot = slot.clone();
let set_slot = slot.clone();
vec![
ToolSpec::new(
"feature_add",
"features",
"Add a feature by catalogue type with a partial params object: every schema key is seeded from its default, `params` is overlaid (nested keys such as boolean.operation allowed), an id is assigned unless given, the result is validated against the kernel schema, then the feature is appended and run. Returns id, index, the run report and the listing. \
The SOLID a feature produced is `after.report.featureOutputs[<feature id>]` — read it rather than assuming a name: a boolean names its result after its target when subtracting, but after its FIRST TOOL when unioning or intersecting.",
{
let mut props = feature_item_schema();
let o = props.as_object_mut().expect("object");
o.insert("wait".into(), json!({ "type": "boolean", "default": true }));
o.insert("timeout_ms".into(), json!({ "type": "integer", "default": 60000 }));
object_schema(props, &["type"])
},
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let slot = add_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let known = known_names(&session).await;
let (id, feature, warnings) = seed_feature(&session, &args, known.as_ref()).await?;
let mut r = session.host.call_ok("feature_add", json!({ "feature": feature })).await?.result.unwrap_or(Value::Null);
if !warnings.is_empty() {
r["warnings"] = json!(warnings);
}
if b(&args, "wait", true) {
r["after"] = after_wait(&session, u(&args, "timeout_ms", 60_000)).await?;
}
session.record("feature_add", &args, true, json!({ "id": id }));
Ok(ToolOutput::json(r))
})
},
),
ToolSpec::new(
"feature_add_many",
"features",
"Add several features in ONE history run: each item takes the same `{type, params, id?, persistent_data?}` shape as `feature_add` and is seeded, given an id and validated the same way. \
ATOMIC in the history — if any item fails to validate nothing is appended, and the error names the item's position and id (the id counter still advances, so a refused batch leaves a gap in the numbering). \
A later item may reference what an earlier one builds. \
Returns the assigned ids, the single run report and the listing, with each feature's output solids under `after.report.featureOutputs`. Use this for patterns and assemblies: N features, one rebuild.",
object_schema(json!({
"features": { "type": "array", "items": { "type": "object", "properties": feature_item_schema(), "required": ["type"], "additionalProperties": false } },
"wait": { "type": "boolean", "default": true },
"timeout_ms": { "type": "integer", "default": 60000 }
}), &["features"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let slot = many_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let items = args
.get("features")
.and_then(Value::as_array)
.ok_or("missing `features` (an array of {type, params, …} items)")?
.clone();
if items.is_empty() {
return Err("`features` is empty".into());
}
let mut known = known_names(&session).await;
let mut ids = Vec::with_capacity(items.len());
let mut features = Vec::with_capacity(items.len());
let mut warnings = Vec::new();
for (index, item) in items.iter().enumerate() {
let (id, feature, mut item_warnings) = seed_feature(&session, item, known.as_ref())
.await
.map_err(|e| format!("features[{index}]: {e} (nothing was added)"))?;
if let Some(known) = known.as_mut() {
known.insert(id.clone());
}
for w in item_warnings.drain(..) {
warnings.push(format!("features[{index}] `{id}`: {w}"));
}
ids.push(id);
features.push(feature);
}
let mut r = session
.host
.call_ok("feature_add_many", json!({ "features": features }))
.await?
.result
.unwrap_or(Value::Null);
r["ids"] = json!(ids);
if !warnings.is_empty() {
r["warnings"] = json!(warnings);
}
if b(&args, "wait", true) {
r["after"] = after_wait(&session, u(&args, "timeout_ms", 60_000)).await?;
}
session.record("feature_add_many", &args, true, json!({ "ids": ids }));
Ok(ToolOutput::json(r))
})
},
),
ToolSpec::new(
"feature_set_params",
"features",
"Merge a patch into a feature's inputParams (nested keys such as boolean.operation allowed), validate, and rerun from it.",
object_schema(json!({ "id": { "type": "string" }, "patch": { "type": "object" }, "wait": { "type": "boolean", "default": true }, "timeout_ms": { "type": "integer", "default": 60000 } }), &["id", "patch"]),
Annotations { read_only: false, destructive: false, idempotent: false, waits: true },
move |args| {
let slot = set_slot.clone();
Box::pin(async move {
let session = current(&slot).await?;
let id = s(&args, "id").ok_or("missing `id`")?;
let cur = session.host.call_ok("feature_params", json!({ "id": id })).await?.result.unwrap_or(Value::Null);
let ty = cur["type"].as_str().unwrap_or("").to_string();
let merged = validate::merge_params(&cur["inputParams"], args.get("patch").unwrap_or(&json!({})));
let known = known_names(&session).await;
let v = validate::validate(&ty, &merged, known.as_ref());
if !v.ok() {
return Err(format!("invalid parameters for {ty}: {}", v.errors.join("; ")));
}
let mut r = session.host.call_ok("feature_set_params", json!({ "id": id, "input_params": merged })).await?.result.unwrap_or(Value::Null);
if !v.warnings.is_empty() {
r["warnings"] = json!(v.warnings);
}
if b(&args, "wait", true) {
r["after"] = after_wait(&session, u(&args, "timeout_ms", 60_000)).await?;
}
session.record("feature_set_params", &args, true, json!({}));
Ok(ToolOutput::json(r))
})
},
),
]
}
async fn known_names(session: &Session) -> Option<std::collections::HashSet<String>> {
let r = session.host.call_ok("scene_entities", json!({})).await.ok()?.result?;
let mut set = std::collections::HashSet::new();
for solid in r["solids"].as_array()? {
if let Some(n) = solid["name"].as_str() {
set.insert(n.to_string());
}
for k in ["faces", "edges"] {
for n in solid[k].as_array().into_iter().flatten() {
if let Some(n) = n.as_str() {
set.insert(n.to_string());
}
}
}
}
Some(set)
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}