use serde::{Deserialize, Serialize};
use serde_json::Value;
use brep_render::engine_state::EngineState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Input,
Mutate,
Read,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct Annotations {
pub read_only: bool,
pub destructive: bool,
pub idempotent: bool,
pub waits: bool,
}
impl Annotations {
pub const READ: Annotations = Annotations { read_only: true, destructive: false, idempotent: true, waits: false };
pub const INPUT: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: false };
pub const MUTATE: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: true };
pub const MUTATE_NOWAIT: Annotations = Annotations { read_only: false, destructive: false, idempotent: true, waits: false };
pub const DESTRUCTIVE: Annotations = Annotations { read_only: false, destructive: true, idempotent: false, waits: true };
}
pub struct Ctx<'a> {
pub app: &'a mut crate::app::BrepApp,
pub egui: &'a egui::Context,
}
pub enum Outcome {
Done(Value),
Blob { json: Value, bytes: Vec<u8> },
AwaitScreenshot { token: u64, region: crate::automation::cmd_capture::Region, document: Option<String> },
Poll(Poller),
}
pub type Poller = Box<dyn FnMut(&mut Ctx<'_>) -> Option<Result<Outcome, String>> + Send>;
pub type InputHandler = fn(&mut crate::automation::pointer::Pointer, Value, &mut Vec<egui::Event>) -> Result<Value, String>;
pub type AppHandler = fn(&mut Ctx<'_>, Value) -> Result<Outcome, String>;
pub enum Handler {
Input(InputHandler),
App(AppHandler),
}
pub struct CommandSpec {
pub name: &'static str,
pub group: &'static str,
pub doc: &'static str,
pub phase: Phase,
pub annotations: Annotations,
pub args_schema: fn() -> Value,
pub result_schema: fn() -> Value,
pub handler: Handler,
}
macro_rules! app_command {
($group:literal, $name:literal, $doc:expr, $args:ty, $handler:path, $phase:ident, $annotation:ident, $output:expr) => {
$crate::automation::command::CommandSpec {
name: $name,
group: $group,
doc: $doc,
phase: $crate::automation::command::Phase::$phase,
annotations: $crate::automation::command::Annotations::$annotation,
args_schema: $crate::automation::command::schema_of::<$args>,
result_schema: $output,
handler: $crate::automation::command::Handler::App($handler),
}
};
($group:literal, $name:literal, $doc:expr, $args:ty, $handler:path, $phase:ident, $annotation:ident) => {
$crate::automation::command::app_command!($group, $name, $doc, $args, $handler, $phase, $annotation, $crate::automation::command::empty_result_schema)
};
}
pub(crate) use app_command;
pub fn engine_call<A, R, E>(ctx: &mut Ctx<'_>, args: Value, f: impl FnOnce(&mut EngineState, A) -> Result<R, E>) -> Result<Outcome, String>
where
A: serde::de::DeserializeOwned,
String: From<E>,
{
let a: A = parse_args(args)?;
f(ctx.app.docs.engine_mut(), a)?;
Ok(Outcome::Done(serde_json::json!({})))
}
pub fn read_engine(ctx: &mut Ctx<'_>, args: Value, f: impl FnOnce(&EngineState) -> Value) -> Result<Outcome, String> {
let _: NoArgs = parse_args(args)?;
Ok(Outcome::Done(f(ctx.app.docs.engine())))
}
pub fn schema_of<T: schemars::JsonSchema>() -> Value {
let mut schema = serde_json::to_value(schemars::schema_for!(T)).unwrap_or(Value::Null);
normalize_bool_subschemas(&mut schema);
schema
}
pub fn empty_result_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
"description": "No native application payload. The MCP adapter adds frame, optional notices and settlement information."
})
}
pub(super) fn normalize_bool_subschemas(node: &mut Value) {
match node {
Value::Object(map) => {
if let Some(Value::Object(props)) = map.get_mut("properties") {
for value in props.values_mut() {
if let Some(b) = value.as_bool() {
*value = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
}
}
}
if let Some(items) = map.get_mut("items") {
if let Some(b) = items.as_bool() {
*items = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
}
}
for value in map.values_mut() {
normalize_bool_subschemas(value);
}
}
Value::Array(arr) => {
for value in arr.iter_mut() {
normalize_bool_subschemas(value);
}
}
_ => {}
}
}
pub fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, String> {
let args = if args.is_null() { Value::Object(Default::default()) } else { args };
serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NoArgs {}
#[derive(Debug, Default, Serialize, schemars::JsonSchema)]
pub struct Empty {}
pub fn registry() -> Vec<&'static CommandSpec> {
use crate::automation::*;
let sets: &[&[CommandSpec]] = &[
cmd_frame::COMMANDS,
cmd_files::COMMANDS,
cmd_perf::COMMANDS,
cmd_input::COMMANDS,
cmd_capture::COMMANDS,
cmd_state::COMMANDS,
cmd_document::COMMANDS,
cmd_classes::COMMANDS,
cmd_history::COMMANDS,
cmd_scene::COMMANDS,
cmd_camera::COMMANDS,
cmd_settings::COMMANDS,
cmd_shell::COMMANDS,
cmd_plugins::COMMANDS,
cmd_metadata::COMMANDS,
cmd_assembly::COMMANDS,
cmd_pmi::COMMANDS,
cmd_sheets::COMMANDS,
cmd_wire_harness::COMMANDS,
cmd_cam::COMMANDS,
cmd_exploded::COMMANDS,
cmd_instructions::COMMANDS,
cmd_instructions_motion::COMMANDS,
cmd_simulation::COMMANDS,
cmd_simulation_play::COMMANDS,
cmd_cam_sim::COMMANDS,
cmd_ecad::COMMANDS,
#[cfg(not(target_arch = "wasm32"))]
cmd_plm::COMMANDS,
#[cfg(not(target_arch = "wasm32"))]
cmd_kicad::COMMANDS,
];
sets.iter().flat_map(|s| s.iter()).collect()
}
pub fn lookup(name: &str) -> Option<&'static CommandSpec> {
registry().into_iter().find(|c| c.name == name)
}
pub fn describe() -> Value {
Value::Array(
registry()
.into_iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"group": c.group,
"doc": c.doc,
"phase": c.phase,
"annotations": c.annotations,
"argsSchema": (c.args_schema)(),
"resultSchema": (c.result_schema)(),
})
})
.collect(),
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
pub id: u64,
pub cmd: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NoticeKind {
Panic,
LogError,
RunnerRefusal,
Toast,
Console,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Notice {
pub kind: NoticeKind,
pub frame: u64,
pub text: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Reply {
pub id: u64,
pub frame: u64,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notices: Vec<Notice>,
#[serde(skip)]
pub blob: Option<Vec<u8>>,
}
impl Reply {
pub fn ok(id: u64, frame: u64, result: Value, notices: Vec<Notice>) -> Self {
Self { id, frame, ok: true, result: Some(result), error: None, notices, blob: None }
}
pub fn err(id: u64, frame: u64, error: impl Into<String>, notices: Vec<Notice>) -> Self {
Self { id, frame, ok: false, result: None, error: Some(error.into()), notices, blob: None }
}
pub fn into_host(mut self) -> brep_mcp_core::host::Reply {
use brep_mcp_core::host::Reply as HostReply;
let blob = self.blob.take();
let (id, frame) = (self.id, self.frame);
match serde_json::to_value(&self).map_err(|e| e.to_string()).and_then(|v| HostReply::from_app(v, blob)) {
Ok(reply) => reply,
Err(e) => HostReply::err(id, frame, e),
}
}
}