forked from 1b-agent/opencadstudio-upstream
feat(mcp): declarative op table and per-variant request validation
- Define Ty, Param, Rule, and OpDef in src/mcp_ops.rs for all 39 mutations - Generate Draft 2020-12 schema using per-op anyOf variants with op enum discriminators - Disambiguate colliding properties (replace, center, name) across mutation variants - Add pre-dispatch validator enforcing required fields, rules, and parameter types - Emit unknown parameter warnings into response warnings - Introduce target_document_id for entities_copy_to with deprecated document_id fallback - Update unit tests and automation smoke assertions
This commit is contained in:
1 parent
74c48ff5e3
commit
6951cc2a7a
5 files changed
+1169
-411
No files matched your search
@@ -52,7 +52,8 @@ def legacy(server: Path) -> None:
|
||||
names = set(definitions)
|
||||
assert names == TOOLS, names
|
||||
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
|
||||
assert len(execute_request["oneOf"]) == 39
|
||||
variants = execute_request.get("anyOf") or execute_request.get("oneOf")
|
||||
assert len(variants) == 39
|
||||
op_enum = execute_request["properties"]["op"]["enum"]
|
||||
for shipped in ("entities_create", "entities_delete", "entities_transform",
|
||||
"block_define", "block_delete", "file_identity", "xdata_set",
|
||||
@@ -65,15 +66,19 @@ def legacy(server: Path) -> None:
|
||||
assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
|
||||
assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
|
||||
assert execute_request["properties"]["steps"]["maxItems"] == 64
|
||||
assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10"
|
||||
variant_map = {
|
||||
v["properties"]["op"].get("const") or v["properties"]["op"]["enum"][0]: v
|
||||
for v in variants
|
||||
}
|
||||
assert variant_map["run"]["properties"]["cmd"]["type"] == "string"
|
||||
assert "set_properties" in execute_request["properties"]["op"]["enum"]
|
||||
assert "save_verified" in execute_request["properties"]["op"]["enum"]
|
||||
assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
|
||||
assert "audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
|
||||
read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"]
|
||||
assert {"collection", "where", "paths", "target_format", "target_version"} <= set(read_parameters)
|
||||
assert execute_request["properties"]["target_version"]["enum"][0] == "R14"
|
||||
assert execute_request["properties"]["kind"]["enum"] == [
|
||||
assert variant_map["save_verified"]["properties"]["target_version"]["enum"][0] == "R14"
|
||||
assert variant_map["input"]["properties"]["kind"]["enum"] == [
|
||||
"text", "token", "point", "entity", "structure", "selection", "enter"
|
||||
]
|
||||
for name in {"ocs_sessions", "ocs_read", "ocs_execute"}:
|
||||
|
||||
@@ -231,8 +231,9 @@ impl OpenCADStudio {
|
||||
req: &Value,
|
||||
) -> Result<Task<Message>, Value> {
|
||||
let handles = crate::app::control::entities::hex_handles(req, "handles")?;
|
||||
let target = req["document_id"]
|
||||
let target = req["target_document_id"]
|
||||
.as_u64()
|
||||
.or_else(|| req["document_id"].as_u64())
|
||||
.ok_or_else(|| failure("document_required", "Supply the target document_id"))?;
|
||||
let source_index = self.active_tab;
|
||||
let target_index = self
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(crate) mod input;
|
||||
pub mod io;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod mcp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod mcp_ops;
|
||||
pub mod modules;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod network;
|
||||
|
||||
+171
-406
@@ -395,11 +395,13 @@ impl GuiClient {
|
||||
"client_id",
|
||||
Value::String(self.client_id.clone()),
|
||||
);
|
||||
insert_default(
|
||||
&mut object,
|
||||
"document_id",
|
||||
self.state["document_id"].clone(),
|
||||
);
|
||||
if op != "entities_copy_to" {
|
||||
insert_default(
|
||||
&mut object,
|
||||
"document_id",
|
||||
self.state["document_id"].clone(),
|
||||
);
|
||||
}
|
||||
insert_default(&mut object, "revision", self.state["revision"].clone());
|
||||
if ["input", "property", "run", "action", "save", "save_verified", "undo", "redo"].contains(&op.as_str())
|
||||
{
|
||||
@@ -655,224 +657,45 @@ fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Strin
|
||||
.ok_or_else(|| format!("Missing {key}"))
|
||||
}
|
||||
|
||||
fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
|
||||
let missing = |field: &str, example: &str| {
|
||||
Err(format!(
|
||||
"Missing {field} for {op}. Example request: {example}"
|
||||
))
|
||||
};
|
||||
match op {
|
||||
"batch" => {
|
||||
let steps = request["steps"].as_array().ok_or_else(|| {
|
||||
r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string()
|
||||
})?;
|
||||
if steps.is_empty() || steps.len() > MAX_BATCH_STEPS {
|
||||
fn validate_execute_request(
|
||||
request: &Value,
|
||||
op: &str,
|
||||
) -> Result<crate::mcp_ops::ValidationResult, String> {
|
||||
if op == "batch" {
|
||||
let steps = request["steps"].as_array().ok_or_else(|| {
|
||||
r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string()
|
||||
})?;
|
||||
if steps.is_empty() || steps.len() > MAX_BATCH_STEPS {
|
||||
return Err(format!(
|
||||
"batch steps must contain 1 to {MAX_BATCH_STEPS} operations"
|
||||
));
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
for (index, step) in steps.iter().enumerate() {
|
||||
let step_obj = step
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("batch step {index} must be an object"))?;
|
||||
if step_obj.contains_key("request_id") {
|
||||
return Err(format!(
|
||||
"batch steps must contain 1 to {MAX_BATCH_STEPS} operations"
|
||||
"batch step {index} must omit request_id; the batch assigns idempotency keys"
|
||||
));
|
||||
}
|
||||
for (index, step) in steps.iter().enumerate() {
|
||||
let step = step
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("batch step {index} must be an object"))?;
|
||||
if step.contains_key("request_id") {
|
||||
return Err(format!(
|
||||
"batch step {index} must omit request_id; the batch assigns idempotency keys"
|
||||
));
|
||||
}
|
||||
let step = Value::Object(step.clone());
|
||||
let step_op = step["op"]
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("batch step {index} is missing op"))?;
|
||||
if !BATCH_STEP_OPS.contains(&step_op) {
|
||||
return Err(format!("Unknown operation {step_op} in batch step {index}"));
|
||||
}
|
||||
validate_execute_request(&step, step_op)
|
||||
.map_err(|error| format!("batch step {index}: {error}"))?;
|
||||
let step_op = step_obj
|
||||
.get("op")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("batch step {index} is missing op"))?;
|
||||
if !BATCH_STEP_OPS.contains(&step_op) {
|
||||
return Err(format!("Unknown operation {step_op} in batch step {index}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"open" if request["path"].as_str().is_none_or(str::is_empty) => {
|
||||
missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#)
|
||||
}
|
||||
"activate" if request["document_id"].as_u64().is_none() => {
|
||||
missing("document_id", r#"{"op":"activate","document_id":2}"#)
|
||||
}
|
||||
"run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => {
|
||||
missing("cmd", r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)
|
||||
}
|
||||
"input" => match request["kind"].as_str() {
|
||||
Some("text") => Ok(()),
|
||||
Some("token")
|
||||
if request["text"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some("token") => missing("text", r#"{"op":"input","kind":"token","text":"C"}"#),
|
||||
Some("point")
|
||||
if request["point"].as_array().is_some_and(|values| {
|
||||
values.len() == 3 && values.iter().all(Value::is_number)
|
||||
}) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some("point") => missing(
|
||||
"point",
|
||||
r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#,
|
||||
),
|
||||
Some("entity" | "structure")
|
||||
if request["handle"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& request["point"].as_array().is_some_and(|values| {
|
||||
values.len() == 3 && values.iter().all(Value::is_number)
|
||||
}) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some("entity" | "structure") => missing(
|
||||
"handle and point",
|
||||
r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#,
|
||||
),
|
||||
Some("selection" | "enter") => Ok(()),
|
||||
Some(kind) => Err(format!(
|
||||
"Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter"
|
||||
)),
|
||||
None => missing("kind", r#"{"op":"input","kind":"point","point":[0,0,0]}"#),
|
||||
},
|
||||
"property"
|
||||
if request["field"].as_str().is_none_or(str::is_empty)
|
||||
|| request.get("value").is_none() =>
|
||||
{
|
||||
missing(
|
||||
"field or value",
|
||||
r#"{"op":"property","field":"color","value":1}"#,
|
||||
)
|
||||
}
|
||||
"set_properties"
|
||||
if request["collection"].as_str().is_none_or(str::is_empty)
|
||||
|| request["updates"].as_array().is_none_or(Vec::is_empty) =>
|
||||
{
|
||||
missing(
|
||||
"collection or updates",
|
||||
r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#,
|
||||
)
|
||||
}
|
||||
"action" => match request["name"].as_str() {
|
||||
Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()),
|
||||
Some(name) => Err(format!(
|
||||
"Unknown action {name}. Call ocs_read commands to list actions"
|
||||
)),
|
||||
None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#),
|
||||
},
|
||||
"embed_image" if request["path"].as_str().is_none_or(str::is_empty) => {
|
||||
missing(
|
||||
"path",
|
||||
r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#,
|
||||
)
|
||||
}
|
||||
"wblock" if request["path"].as_str().is_none_or(str::is_empty) => {
|
||||
missing(
|
||||
"path",
|
||||
r#"{"op":"wblock","path":"/path/part.dwg","handles":["2A","31"]}"#,
|
||||
)
|
||||
}
|
||||
"plot" if request["path"].as_str().is_none_or(str::is_empty) => {
|
||||
missing(
|
||||
"path",
|
||||
r#"{"op":"plot","path":"/path/pages.pdf","layout":"Model"}"#,
|
||||
)
|
||||
}
|
||||
"entities_create"
|
||||
if request["entities"].as_array().is_none_or(Vec::is_empty) =>
|
||||
{
|
||||
missing(
|
||||
"entities",
|
||||
r#"{"op":"entities_create","entities":[{"type":"Circle","center":[0,0,0],"radius":5}]}"#,
|
||||
)
|
||||
}
|
||||
"entities_delete" | "entities_transform" | "view_focus"
|
||||
if request["handles"].as_array().is_none_or(Vec::is_empty) =>
|
||||
{
|
||||
missing(
|
||||
"handles",
|
||||
r#"{"op":"entities_delete","handles":["2A","31"]}"#,
|
||||
)
|
||||
}
|
||||
"block_define"
|
||||
if request["name"].as_str().is_none_or(str::is_empty)
|
||||
|| request["handles"].as_array().is_none_or(Vec::is_empty)
|
||||
|| request["base"].as_array().is_none() =>
|
||||
{
|
||||
missing(
|
||||
"name, handles and base",
|
||||
r#"{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["2A"]}"#,
|
||||
)
|
||||
}
|
||||
"xdata_set"
|
||||
if request["app"].as_str().is_none_or(str::is_empty)
|
||||
|| request["handles"].as_array().is_none_or(Vec::is_empty) =>
|
||||
{
|
||||
missing(
|
||||
"app and handles",
|
||||
r#"{"op":"xdata_set","app":"SPM","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#,
|
||||
)
|
||||
}
|
||||
"entities_copy_to"
|
||||
if request["handles"].as_array().is_none_or(Vec::is_empty)
|
||||
|| request["document_id"].as_u64().is_none() =>
|
||||
{
|
||||
missing(
|
||||
"handles and document_id",
|
||||
r#"{"op":"entities_copy_to","handles":["2A"],"document_id":2}"#,
|
||||
)
|
||||
}
|
||||
"group_create" | "selection_set_save"
|
||||
if request["name"].as_str().is_none_or(str::is_empty)
|
||||
|| request["handles"].as_array().is_none_or(Vec::is_empty) =>
|
||||
{
|
||||
missing(
|
||||
"name and handles",
|
||||
r#"{"op":"group_create","name":"Frame","handles":["2A"]}"#,
|
||||
)
|
||||
}
|
||||
"selection_set_load" if request["name"].as_str().is_none_or(str::is_empty) => {
|
||||
missing("name", r#"{"op":"selection_set_load","name":"Frame"}"#)
|
||||
}
|
||||
"block_delete" if request["name"].as_str().is_none_or(str::is_empty) => {
|
||||
missing("name", r#"{"op":"block_delete","name":"Frame"}"#)
|
||||
}
|
||||
"layout_create" if request["name"].as_str().is_none_or(str::is_empty) => {
|
||||
missing("name", r#"{"op":"layout_create","name":"Plan"}"#)
|
||||
}
|
||||
"page_setup_set" if request["layout"].as_str().is_none_or(str::is_empty) => {
|
||||
missing(
|
||||
"layout",
|
||||
r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#,
|
||||
)
|
||||
}
|
||||
"save_verified" if request["path"].as_str().is_none_or(str::is_empty) => {
|
||||
missing(
|
||||
"path",
|
||||
r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#,
|
||||
)
|
||||
}
|
||||
"text_replace" => {
|
||||
if request["pairs"].as_array().is_none()
|
||||
&& request["find"].as_str().is_none_or(str::is_empty)
|
||||
{
|
||||
missing(
|
||||
"find or pairs",
|
||||
r#"{"op":"text_replace","request_id":"tr1","find":"OLD","replace":"NEW"}"#,
|
||||
)
|
||||
} else {
|
||||
Ok(())
|
||||
let step_val = crate::mcp_ops::validate_request(step, true)
|
||||
.map_err(|error| format!("batch step {index}: {error}"))?;
|
||||
for w in step_val.warnings {
|
||||
warnings.push(format!("batch step {index}: {w}"));
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
return Ok(crate::mcp_ops::ValidationResult { warnings });
|
||||
}
|
||||
crate::mcp_ops::validate_request(request, false)
|
||||
}
|
||||
|
||||
fn compact_state(state: &Value) -> Value {
|
||||
@@ -976,15 +799,23 @@ fn call_tool(
|
||||
if request_id.len() > 128 {
|
||||
return Err("request_id must not exceed 128 bytes".into());
|
||||
}
|
||||
validate_execute_request(&request, op)?;
|
||||
let val_res = validate_execute_request(&request, op)?;
|
||||
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
|
||||
let detail = arguments["response_detail"].as_str().unwrap_or("compact");
|
||||
let gui = client(clients, session_id)?;
|
||||
let response = if op == "batch" {
|
||||
let mut response = if op == "batch" {
|
||||
gui.execute_batch(request, wait)?
|
||||
} else {
|
||||
gui.request(request, wait)?
|
||||
};
|
||||
if !val_res.warnings.is_empty() {
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
object.insert(
|
||||
"warnings".into(),
|
||||
Value::Array(val_res.warnings.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
}
|
||||
shape_execute_response(response, detail, gui)
|
||||
}
|
||||
"ocs_capture" => {
|
||||
@@ -1007,184 +838,13 @@ fn call_tool(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn batch_step_schema() -> Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"op":{"type":"string","enum":BATCH_STEP_OPS},
|
||||
"document_id":{"type":"integer","minimum":0},
|
||||
"cmd":{"type":"string","minLength":1},
|
||||
"path":{"type":"string","minLength":1},
|
||||
"target_format":{"type":"string","enum":["dwg","dxf"]},
|
||||
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"]},
|
||||
"allow_lossy":{"type":"boolean","default":false},
|
||||
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]},
|
||||
"text":{"type":"string"},
|
||||
"point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},
|
||||
"space":{"type":"string","enum":["wcs","ucs","relative"]},
|
||||
"handle":{"type":"string"},
|
||||
"handles":{"type":"array","items":{"type":"string"}},
|
||||
"type":{"type":"string"},
|
||||
"layer":{"type":"string"},
|
||||
"clear":{"type":"boolean"},
|
||||
"field":{"type":"string"},
|
||||
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
|
||||
"collection":{"type":"string"},
|
||||
"updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}},
|
||||
"name":{"type":"string","enum":crate::app::automation_action_names()}
|
||||
},
|
||||
"required":["op"],
|
||||
"additionalProperties":false
|
||||
})
|
||||
crate::mcp_ops::batch_step_schema()
|
||||
}
|
||||
|
||||
fn execute_request_schema() -> Value {
|
||||
let handle = json!({
|
||||
"type":"string",
|
||||
"pattern":"^(0[xX])?[0-9A-Fa-f]+$",
|
||||
"description":"Entity handle returned by ocs_read entities or state selection."
|
||||
});
|
||||
let point = json!({
|
||||
"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,
|
||||
"description":"Finite [x,y,z] coordinates."
|
||||
});
|
||||
json!({
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},
|
||||
"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},
|
||||
"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},
|
||||
"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},
|
||||
"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},
|
||||
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
|
||||
"selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."},
|
||||
"cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]},
|
||||
"find":{"type":"string","description":"Search text to replace; supports DXF Unicode escapes and special characters."},
|
||||
"replace":{"type":"string","description":"Replacement text."},
|
||||
"pairs":{"type":"array","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]},"description":"Batch find/replace pairs executed in a single atomic undo transaction."},
|
||||
"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."},
|
||||
"whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."},
|
||||
"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},
|
||||
"dry_run":{"type":"boolean","default":false,"description":"When true, simulates replacement without mutating entities or pushing undo."},
|
||||
"replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."},
|
||||
"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."},
|
||||
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."},
|
||||
"block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."},
|
||||
"linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."},
|
||||
"layout":{"type":"string","description":"plot: Model (default), a layout name, or all."},
|
||||
"area":{"type":"string","enum":["extents","display","limits","window","layout"],"description":"plot area; layout applies to paper-space layouts."},
|
||||
"window":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"World [x0,y0,x1,y1] plot rectangle for area window."},
|
||||
"paper":{"type":"string","description":"plot: canonical paper-catalog sheet name."},
|
||||
"orientation":{"type":"string","enum":["Portrait","Landscape"],"description":"plot sheet orientation."},
|
||||
"fit":{"type":"boolean","description":"plot: scale content to fit the sheet (default true)."},
|
||||
"scale":{"type":"string","description":"plot scale as paper:drawing, e.g. 1:100; disables fit."},
|
||||
"center":{"type":"boolean","description":"plot: center the content on the sheet (default true)."},
|
||||
"offset_x":{"type":"number","description":"plot offset when not centered."},
|
||||
"offset_y":{"type":"number","description":"plot offset when not centered."},
|
||||
"upside_down":{"type":"boolean","description":"plot: rotate content 180 degrees."},
|
||||
"plot_style":{"type":"string","description":"plot: CTB file path or a discovered plot style name."},
|
||||
"transparency":{"type":"boolean","description":"plot: keep transparency."},
|
||||
"lineweights":{"type":"boolean","description":"plot: honor object lineweights."},
|
||||
"merge_lines":{"type":"boolean","description":"plot: merge overlapping lines."},
|
||||
"stamp":{"type":"boolean","description":"plot: draw the plot stamp."},
|
||||
"entities":{"type":"array","minItems":1,"description":"entities_create: typed definitions, one object per entity. type is one of Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch; geometry fields depend on the type; layer and color (ACI) are optional and a missing layer is created.","items":{"type":"object"}},
|
||||
"action":{"type":"string","enum":["move","copy","rotate","scale","mirror","array"],"description":"entities_transform action (default move)."},
|
||||
"vector":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"move/copy displacement [dx,dy(,dz)]."},
|
||||
"center":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"rotate/scale center point."},
|
||||
"angle_deg":{"type":"number","description":"rotate angle, degrees CCW."},
|
||||
"factor":{"type":"number","exclusiveMinimum":0,"description":"scale factor."},
|
||||
"axis":{"type":"array","minItems":2,"maxItems":2,"description":"mirror line [[x1,y1],[x2,y2]].","items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}},
|
||||
"copy":{"type":"boolean","description":"mirror: keep the originals and return mirrored copies."},
|
||||
"rows":{"type":"integer","minimum":1,"description":"array row count (default 1)."},
|
||||
"columns":{"type":"integer","minimum":1,"description":"array column count (default 1)."},
|
||||
"row_spacing":{"type":"number","description":"array row spacing."},
|
||||
"column_spacing":{"type":"number","description":"array column spacing."},
|
||||
"base":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define base point (becomes the block origin)."},
|
||||
"insert_at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define Insert placement (default = base)."},
|
||||
"app":{"type":"string","minLength":1,"description":"xdata_set: application name; its RegApp table entry is registered automatically."},
|
||||
"data":{"type":"array","description":"xdata_set typed values [{code,value}]: 1000 string, 1003 layer, 1004 hex bytes, 1005 hex handle, 1010-1013 [x,y,z], 1040/1041/1042 real, 1070 int16, 1071 int32. An empty or absent list removes the application record.","items":{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}},
|
||||
"highlight":{"type":"boolean","description":"view_focus: also select the entities (default true)."},
|
||||
"template":{"type":"string","description":"new/wblock: load this DWG/DXF/DWT file as the base document so its tables and styles survive."},
|
||||
"discard":{"type":"boolean","description":"close: true erases unsaved changes instead of refusing a dirty document."},
|
||||
"per_page":{"type":"boolean","description":"plot: write one PDF per layout as <stem>-<Layout>.pdf."},
|
||||
"get":{"type":"array","items":{"type":"string"},"description":"sysvar: variable names to read (ltscale, pdmode, pdsize, celtscale, textsize, filletrad, mirrtext, insunits, osmode, clayer, ctextstyle, extmin, extmax)."},
|
||||
"set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true},
|
||||
"select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."},
|
||||
"where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}},
|
||||
"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Explicit output format; it must match the path extension."},
|
||||
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Explicit CAD output version. Omit only to preserve the document version."},
|
||||
"allow_lossy":{"type":"boolean","default":false,"description":"Acknowledge dropping unsupported passthrough records reported by audit."},
|
||||
"overwrite":{"type":"boolean","default":false,"description":"For save_verified only: replace an existing destination."},
|
||||
"at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."},
|
||||
"width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."},
|
||||
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."},
|
||||
"text":{"type":"string","description":"Free text or one option/value token."},
|
||||
"point":point,
|
||||
"space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."},
|
||||
"handle":handle.clone(),
|
||||
"handles":{"type":"array","items":handle,"description":"Entity handles: selection filter for select, export set for wblock."},
|
||||
"type":{"type":"string","description":"Entity type filter for select."},
|
||||
"layer":{"type":"string","description":"Layer filter for select."},
|
||||
"clear":{"type":"boolean","description":"Clear the current selection before applying select filters."},
|
||||
"field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."},
|
||||
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
|
||||
"collection":{"type":"string","description":"Record collection returned by ocs_read records."},
|
||||
"updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}},
|
||||
"name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."},
|
||||
"steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()}
|
||||
},
|
||||
"required":["op","request_id"],
|
||||
"additionalProperties":false,
|
||||
"oneOf":[
|
||||
{"properties":{"op":{"const":"new"}}},
|
||||
{"properties":{"op":{"const":"open"}},"required":["path"]},
|
||||
{"properties":{"op":{"const":"activate"}},"required":["document_id"]},
|
||||
{"properties":{"op":{"const":"run"}},"required":["cmd"]},
|
||||
{"properties":{"op":{"const":"start"}},"required":["cmd"]},
|
||||
{"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[
|
||||
{"properties":{"kind":{"const":"text"}}},
|
||||
{"properties":{"kind":{"const":"token"}},"required":["text"]},
|
||||
{"properties":{"kind":{"const":"point"}},"required":["point"]},
|
||||
{"properties":{"kind":{"const":"entity"}},"required":["handle","point"]},
|
||||
{"properties":{"kind":{"const":"structure"}},"required":["handle","point"]},
|
||||
{"properties":{"kind":{"const":"selection"}}},
|
||||
{"properties":{"kind":{"const":"enter"}}}
|
||||
]},
|
||||
{"properties":{"op":{"const":"cancel"}}},
|
||||
{"properties":{"op":{"const":"undo"}}},
|
||||
{"properties":{"op":{"const":"redo"}}},
|
||||
{"properties":{"op":{"const":"select"}}},
|
||||
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
|
||||
{"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]},
|
||||
{"properties":{"op":{"const":"action"}},"required":["name"]},
|
||||
{"properties":{"op":{"const":"embed_image"}},"required":["path"]},
|
||||
{"properties":{"op":{"const":"wblock"}},"required":["path"]},
|
||||
{"properties":{"op":{"const":"plot"}},"required":["path"]},
|
||||
{"properties":{"op":{"const":"entities_create"}},"required":["entities"]},
|
||||
{"properties":{"op":{"const":"entities_delete"}},"required":["handles"]},
|
||||
{"properties":{"op":{"const":"entities_transform"}},"required":["handles"]},
|
||||
{"properties":{"op":{"const":"text_replace"}}},
|
||||
{"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]},
|
||||
{"properties":{"op":{"const":"block_delete"}},"required":["name"]},
|
||||
{"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]},
|
||||
{"properties":{"op":{"const":"view_focus"}},"required":["handles"]},
|
||||
{"properties":{"op":{"const":"entities_copy_to"}},"required":["handles","document_id"]},
|
||||
{"properties":{"op":{"const":"group_create"}},"required":["name","handles"]},
|
||||
{"properties":{"op":{"const":"selection_set_save"}},"required":["name","handles"]},
|
||||
{"properties":{"op":{"const":"selection_set_load"}},"required":["name"]},
|
||||
{"properties":{"op":{"const":"user_select"}},"description":"Ask the person at the screen to pick entities; resolves when they press Enter (or cancel on Escape). Optional type/layer/prompt/detail/clear."},
|
||||
{"properties":{"op":{"const":"getpoint"}},"description":"Ask the person at the screen to pick one point; resolves with the picked point when they click, or cancels on Escape. Optional prompt."},
|
||||
{"properties":{"op":{"const":"close"}}},
|
||||
{"properties":{"op":{"const":"sysvar"}}},
|
||||
{"properties":{"op":{"const":"layout_create"}},"required":["name"]},
|
||||
{"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]},
|
||||
{"properties":{"op":{"const":"file_identity"}}},
|
||||
{"properties":{"op":{"const":"save"}}},
|
||||
{"properties":{"op":{"const":"save_verified"}},"required":["path"]},
|
||||
{"properties":{"op":{"const":"stop"}}},
|
||||
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
|
||||
]
|
||||
})
|
||||
crate::mcp_ops::execute_request_schema()
|
||||
}
|
||||
|
||||
fn read_output_schema() -> Value {
|
||||
@@ -1569,13 +1229,10 @@ mod tests {
|
||||
tools[2]["inputSchema"]["properties"]["request"]["required"],
|
||||
json!(["op", "request_id"])
|
||||
);
|
||||
assert_eq!(
|
||||
tools[2]["inputSchema"]["properties"]["request"]["oneOf"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
EXECUTE_OPS.len()
|
||||
);
|
||||
let request_variants = tools[2]["inputSchema"]["properties"]["request"]["anyOf"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(request_variants.len(), EXECUTE_OPS.len());
|
||||
assert_eq!(
|
||||
tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"],
|
||||
MAX_BATCH_STEPS
|
||||
@@ -1588,10 +1245,15 @@ mod tests {
|
||||
tools[3]["inputSchema"]["properties"]["scope"]["default"],
|
||||
"viewport"
|
||||
);
|
||||
assert_eq!(
|
||||
tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0],
|
||||
"LINE 0,0 10,10"
|
||||
);
|
||||
let find_variant = |op_name: &str| {
|
||||
request_variants
|
||||
.iter()
|
||||
.find(|v| v["properties"]["op"]["enum"][0] == op_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("variant for {op_name} exists"))
|
||||
};
|
||||
let run_var = find_variant("run");
|
||||
assert_eq!(run_var["properties"]["cmd"]["type"], "string");
|
||||
assert!(READ_OPS.contains(&"capabilities"));
|
||||
assert!(READ_OPS.contains(&"records"));
|
||||
assert!(READ_OPS.contains(&"record_schema"));
|
||||
@@ -1602,9 +1264,9 @@ mod tests {
|
||||
assert!(EXECUTE_OPS.contains(&"save_verified"));
|
||||
assert!(EXECUTE_OPS.contains(&"text_replace"));
|
||||
assert!(BATCH_STEP_OPS.contains(&"text_replace"));
|
||||
let save_var = find_variant("save_verified");
|
||||
assert_eq!(
|
||||
tools[2]["inputSchema"]["properties"]["request"]["properties"]
|
||||
["target_version"]["enum"][0],
|
||||
save_var["properties"]["target_version"]["enum"][0],
|
||||
"R14"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1625,8 +1287,9 @@ mod tests {
|
||||
"not_exists"
|
||||
])
|
||||
);
|
||||
let set_props_var = find_variant("set_properties");
|
||||
assert_eq!(
|
||||
tools[2]["inputSchema"]["properties"]["request"]["properties"]["updates"]["items"]["required"],
|
||||
set_props_var["properties"]["updates"]["items"]["required"],
|
||||
json!(["path", "value"])
|
||||
);
|
||||
assert!(tools[0].get("outputSchema").is_some());
|
||||
@@ -1823,4 +1486,106 @@ mod tests {
|
||||
assert_eq!(result["isError"], true);
|
||||
assert_eq!(result["structuredContent"]["code"], "stale_state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_op_examples_pass_pre_dispatch_validation() {
|
||||
for op in crate::mcp_ops::OPS {
|
||||
let mut req: Value = serde_json::from_str(op.example)
|
||||
.unwrap_or_else(|e| panic!("invalid json example for {}: {e}", op.name));
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
obj.insert("request_id".into(), Value::String("example-id".into()));
|
||||
}
|
||||
let res = crate::mcp_ops::validate_request(&req, false);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"example for op '{}' failed validation: {:?}",
|
||||
op.name,
|
||||
res.err()
|
||||
);
|
||||
|
||||
if op.batchable {
|
||||
let mut batch_req: Value = serde_json::from_str(op.example).unwrap();
|
||||
if let Some(obj) = batch_req.as_object_mut() {
|
||||
obj.remove("request_id");
|
||||
}
|
||||
let b_res = crate::mcp_ops::validate_request(&batch_req, true);
|
||||
assert!(
|
||||
b_res.is_ok(),
|
||||
"batch example for op '{}' failed validation: {:?}",
|
||||
op.name,
|
||||
b_res.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_dispatch_validator_catches_unknown_parameters_as_warnings() {
|
||||
let req = json!({
|
||||
"op": "run",
|
||||
"request_id": "r1",
|
||||
"cmd": "LINE 0,0 10,0",
|
||||
"extra_param": 123
|
||||
});
|
||||
let res = crate::mcp_ops::validate_request(&req, false).expect("validation passes");
|
||||
assert_eq!(res.warnings.len(), 1);
|
||||
assert!(res.warnings[0].contains("unknown parameter 'extra_param' ignored for op 'run'"));
|
||||
|
||||
// Batch step warnings propagation
|
||||
let batch = json!({
|
||||
"op": "batch",
|
||||
"request_id": "b1",
|
||||
"steps": [
|
||||
{"op": "run", "cmd": "LINE 0,0 10,0", "stray": true}
|
||||
]
|
||||
});
|
||||
let batch_res = validate_execute_request(&batch, "batch").expect("batch passes");
|
||||
assert_eq!(batch_res.warnings.len(), 1);
|
||||
assert!(batch_res.warnings[0].contains("batch step 0: unknown parameter 'stray' ignored for op 'run'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_dispatch_validator_enforces_required_fields_and_rules() {
|
||||
// Missing required field
|
||||
let missing_cmd = json!({"op": "run", "request_id": "r1"});
|
||||
let err = crate::mcp_ops::validate_request(&missing_cmd, false).unwrap_err();
|
||||
assert!(err.contains("Missing cmd for run. Example request:"), "{err}");
|
||||
|
||||
// Required-when rule (scale requires factor)
|
||||
let scale_missing_factor = json!({
|
||||
"op": "entities_transform",
|
||||
"request_id": "r2",
|
||||
"handles": ["2A"],
|
||||
"action": "scale"
|
||||
});
|
||||
let err2 = crate::mcp_ops::validate_request(&scale_missing_factor, false).unwrap_err();
|
||||
assert!(err2.contains("Missing factor for entities_transform. Example request:"), "{err2}");
|
||||
|
||||
// Hex handle enforcement
|
||||
let invalid_handle = json!({
|
||||
"op": "entities_delete",
|
||||
"request_id": "r3",
|
||||
"handles": ["NOT_A_HEX_HANDLE!"]
|
||||
});
|
||||
let err3 = crate::mcp_ops::validate_request(&invalid_handle, false).unwrap_err();
|
||||
assert!(err3.contains("hexadecimal handle strings"), "{err3}");
|
||||
|
||||
// Non-batchable op rejected in batch
|
||||
let stop_in_batch = json!({"op": "stop"});
|
||||
let err4 = crate::mcp_ops::validate_request(&stop_in_batch, true).unwrap_err();
|
||||
assert!(err4.contains("operation 'stop' cannot be used in a batch step"), "{err4}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_size_and_defs_structure() {
|
||||
let schema = execute_request_schema();
|
||||
assert_eq!(schema["$schema"], "https://json-schema.org/draft/2020-12/schema");
|
||||
assert!(schema["$defs"]["handle"].is_object());
|
||||
assert!(schema["$defs"]["point"].is_object());
|
||||
assert!(schema["$defs"]["window"].is_object());
|
||||
assert!(schema["$defs"]["batch_step"].is_object());
|
||||
let json_str = schema.to_string();
|
||||
// Generates cleanly without exponential blowup: compact ~15KB to 30KB
|
||||
assert!(json_str.len() < 40_000, "schema size is {} bytes", json_str.len());
|
||||
}
|
||||
}
|
||||
+985
@@ -0,0 +1,985 @@
|
||||
//! Declarative description of every MCP op. Single source of truth for the
|
||||
//! advertised JSON Schema, the pre-dispatch validator, and test examples.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub const TARGET_VERSIONS: &[&str] = &[
|
||||
"R14", "2000", "2004", "2007", "2010", "2013", "2018",
|
||||
"AC1014", "AC1015", "AC1018", "AC1021", "AC1024", "AC1027", "AC1032",
|
||||
];
|
||||
|
||||
pub const ENVELOPE_KEYS: &[&str] = &[
|
||||
"op",
|
||||
"request_id",
|
||||
"document_id",
|
||||
"revision",
|
||||
"geometry_revision",
|
||||
"camera_revision",
|
||||
"selection",
|
||||
"client_id",
|
||||
"steps",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Ty {
|
||||
Str,
|
||||
Bool,
|
||||
Num,
|
||||
Int,
|
||||
/// [x, y] or [x, y, z]; a missing z is 0.0 at runtime.
|
||||
Point,
|
||||
/// Hex string, optional 0x prefix. JSON integers are rejected at runtime.
|
||||
Handle,
|
||||
/// Non-empty array of Handle.
|
||||
Handles,
|
||||
/// [x0, y0, x1, y1]
|
||||
Window,
|
||||
Enum(&'static [&'static str]),
|
||||
/// Runtime compares case-insensitively; the schema advertises canonical case.
|
||||
EnumCi(&'static [&'static str]),
|
||||
ActionName,
|
||||
ArrayOf(&'static Ty),
|
||||
/// Escape hatch: a JSON Schema fragment as text.
|
||||
Raw(&'static str),
|
||||
}
|
||||
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Ty,
|
||||
pub required: bool,
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
pub enum Rule {
|
||||
/// At least one of these keys must be present.
|
||||
AnyOf(&'static [&'static str]),
|
||||
/// If `key` equals `equals`, every key in `then` must be present.
|
||||
RequiredWhen {
|
||||
key: &'static str,
|
||||
equals: &'static str,
|
||||
then: &'static [&'static str],
|
||||
},
|
||||
}
|
||||
|
||||
pub struct OpDef {
|
||||
pub name: &'static str,
|
||||
pub doc: &'static str,
|
||||
pub batchable: bool,
|
||||
pub params: &'static [Param],
|
||||
pub rules: &'static [Rule],
|
||||
/// Minimal valid payload (JSON text). Drives schema, validator and parity tests.
|
||||
pub example: &'static str,
|
||||
}
|
||||
|
||||
const fn req(name: &'static str, ty: Ty, doc: &'static str) -> Param {
|
||||
Param { name, ty, required: true, doc }
|
||||
}
|
||||
|
||||
const fn opt(name: &'static str, ty: Ty, doc: &'static str) -> Param {
|
||||
Param { name, ty, required: false, doc }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OP DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
pub static NEW: OpDef = OpDef {
|
||||
name: "new",
|
||||
doc: "Start an empty document, optionally seeded from a DWG/DXF/DWT template.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("template", Ty::Str, "Optional template drawing path whose styles and tables survive."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"new"}"#,
|
||||
};
|
||||
|
||||
pub static OPEN: OpDef = OpDef {
|
||||
name: "open",
|
||||
doc: "Open an existing drawing file (DWG or DXF).",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("path", Ty::Str, "Absolute path to the drawing file."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"open","path":"drawing.dxf"}"#,
|
||||
};
|
||||
|
||||
pub static ACTIVATE: OpDef = OpDef {
|
||||
name: "activate",
|
||||
doc: "Switch the active drawing tab to another open document.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("document_id", Ty::Int, "Target document ID to activate."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"activate","document_id":1}"#,
|
||||
};
|
||||
|
||||
pub static RUN: OpDef = OpDef {
|
||||
name: "run",
|
||||
doc: "Execute a command string with prompt answers separated by spaces (e.g. 'LINE 0,0 10,10').",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("cmd", Ty::Str, "Command name and arguments."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#,
|
||||
};
|
||||
|
||||
pub static START: OpDef = OpDef {
|
||||
name: "start",
|
||||
doc: "Start an interactive command waiting for subsequent 'input' steps.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("cmd", Ty::Str, "Command name to start."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"start","cmd":"LINE"}"#,
|
||||
};
|
||||
|
||||
pub static INPUT: OpDef = OpDef {
|
||||
name: "input",
|
||||
doc: "Feed the next step input to an active command.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("kind", Ty::Enum(&["text", "token", "point", "entity", "structure", "selection", "enter"]), "Input kind."),
|
||||
opt("text", Ty::Str, "Free text or token option."),
|
||||
opt("point", Ty::Point, "Point coordinates."),
|
||||
opt("space", Ty::Enum(&["wcs", "ucs", "relative"]), "Coordinate space for point (defaults to wcs)."),
|
||||
opt("handle", Ty::Handle, "Target entity handle for entity/structure pick."),
|
||||
],
|
||||
rules: &[
|
||||
Rule::RequiredWhen { key: "kind", equals: "token", then: &["text"] },
|
||||
Rule::RequiredWhen { key: "kind", equals: "point", then: &["point"] },
|
||||
Rule::RequiredWhen { key: "kind", equals: "entity", then: &["handle", "point"] },
|
||||
Rule::RequiredWhen { key: "kind", equals: "structure", then: &["handle", "point"] },
|
||||
],
|
||||
example: r#"{"op":"input","kind":"point","point":[10,20]}"#,
|
||||
};
|
||||
|
||||
pub static CANCEL: OpDef = OpDef {
|
||||
name: "cancel",
|
||||
doc: "Cancel the active command, equivalent to pressing Escape.",
|
||||
batchable: true,
|
||||
params: &[],
|
||||
rules: &[],
|
||||
example: r#"{"op":"cancel"}"#,
|
||||
};
|
||||
|
||||
pub static UNDO: OpDef = OpDef {
|
||||
name: "undo",
|
||||
doc: "Undo the last drawing modification.",
|
||||
batchable: true,
|
||||
params: &[],
|
||||
rules: &[],
|
||||
example: r#"{"op":"undo"}"#,
|
||||
};
|
||||
|
||||
pub static REDO: OpDef = OpDef {
|
||||
name: "redo",
|
||||
doc: "Redo the previously undone drawing modification.",
|
||||
batchable: true,
|
||||
params: &[],
|
||||
rules: &[],
|
||||
example: r#"{"op":"redo"}"#,
|
||||
};
|
||||
|
||||
pub static SELECT: OpDef = OpDef {
|
||||
name: "select",
|
||||
doc: "Modify current entity selection by handles, filters, or query criteria.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("handles", Ty::Handles, "Explicit entity handles to select."),
|
||||
opt("type", Ty::Str, "Entity type filter."),
|
||||
opt("layer", Ty::Str, "Layer name filter."),
|
||||
opt("clear", Ty::Bool, "Clear existing selection before applying."),
|
||||
opt("where", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}"#)), "Property filter predicates."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"select","clear":true}"#,
|
||||
};
|
||||
|
||||
pub static PROPERTY: OpDef = OpDef {
|
||||
name: "property",
|
||||
doc: "Modify a single property of selected entities.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("field", Ty::Str, "Property field name."),
|
||||
req("value", Ty::Raw(r#"{"description":"New property value"}"#), "New value for the field."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"property","field":"color","value":1}"#,
|
||||
};
|
||||
|
||||
pub static SET_PROPERTIES: OpDef = OpDef {
|
||||
name: "set_properties",
|
||||
doc: "Atomically update record properties by RFC 6901 JSON pointer paths.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("collection", Ty::Str, "Record collection name."),
|
||||
opt("handle", Ty::Handle, "Target record handle if applicable."),
|
||||
req("updates", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"path":{"type":"string"},"value":{},"expected":{}},"required":["path","value"]}"#)), "List of pointer replacements."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#,
|
||||
};
|
||||
|
||||
pub static ACTION: OpDef = OpDef {
|
||||
name: "action",
|
||||
doc: "Trigger a named GUI action or dialog command.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::ActionName, "Name of the UI action to execute."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"action","name":"zoom_extents"}"#,
|
||||
};
|
||||
|
||||
pub static EMBED_IMAGE: OpDef = OpDef {
|
||||
name: "embed_image",
|
||||
doc: "Embed or link a raster image into the drawing.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("path", Ty::Str, "Path to the image file."),
|
||||
opt("at", Ty::Point, "Lower-left corner [x, y] or [x, y, z]."),
|
||||
opt("width", Ty::Num, "World width of the image."),
|
||||
opt("linked", Ty::Bool, "Store as path-linked RasterImage instead of embedded OLE2FRAME."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"embed_image","path":"logo.png","at":[0,0]}"#,
|
||||
};
|
||||
|
||||
pub static WBLOCK: OpDef = OpDef {
|
||||
name: "wblock",
|
||||
doc: "Export selected entities or a block definition to an external DWG/DXF file.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("path", Ty::Str, "Target drawing path for export."),
|
||||
opt("handles", Ty::Handles, "Entity handles to export."),
|
||||
opt("block", Ty::Str, "Block name to export."),
|
||||
opt("template", Ty::Str, "Base template file to carry styles and tables."),
|
||||
opt("normalize", Ty::Bool, "Translate exported entities to origin [0,0,0]."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"wblock","path":"part.dwg","handles":["2A"]}"#,
|
||||
};
|
||||
|
||||
pub static PLOT: OpDef = OpDef {
|
||||
name: "plot",
|
||||
doc: "Plot layouts to a PDF file. scale (e.g. \"1:100\") overrides fit; fit defaults to true when scale is omitted.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("path", Ty::Str, "Output PDF file path (must end in .pdf)."),
|
||||
opt("layout", Ty::Str, "Target layout name or 'all' (defaults to 'Model')."),
|
||||
opt("area", Ty::EnumCi(&["extents", "display", "limits", "layout", "window"]), "Plot area (defaults to extents)."),
|
||||
opt("window", Ty::Window, "World [x0, y0, x1, y1] rectangle; required when area=window."),
|
||||
opt("paper", Ty::Str, "Canonical paper size name from paper catalog."),
|
||||
opt("orientation", Ty::EnumCi(&["Portrait", "Landscape"]), "Sheet orientation."),
|
||||
opt("fit", Ty::Bool, "Fit drawing to sheet (defaults to true if scale omitted)."),
|
||||
opt("scale", Ty::Str, "Plot scale as paper:drawing ratio (e.g. \"1:100\")."),
|
||||
opt("center", Ty::Bool, "Center plot on sheet (defaults to true)."),
|
||||
opt("offset_x", Ty::Num, "Plot offset X in mm."),
|
||||
opt("offset_y", Ty::Num, "Plot offset Y in mm."),
|
||||
opt("upside_down", Ty::Bool, "Rotate content 180 degrees."),
|
||||
opt("plot_style", Ty::Str, "CTB file path or named plot style table."),
|
||||
opt("transparency", Ty::Bool, "Honor transparency settings."),
|
||||
opt("lineweights", Ty::Bool, "Honor lineweight settings."),
|
||||
opt("merge_lines", Ty::Bool, "Merge overlapping lines."),
|
||||
opt("stamp", Ty::Bool, "Print plot stamp."),
|
||||
opt("per_page", Ty::Bool, "When layout='all', write one PDF per layout as <stem>-<Layout>.pdf."),
|
||||
],
|
||||
rules: &[Rule::RequiredWhen { key: "area", equals: "window", then: &["window"] }],
|
||||
example: r#"{"op":"plot","path":"out.pdf","area":"extents","scale":"1:100"}"#,
|
||||
};
|
||||
|
||||
pub static ENTITIES_CREATE: OpDef = OpDef {
|
||||
name: "entities_create",
|
||||
doc: "Create new CAD entities from typed definitions.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("entities", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"type":{"type":"string"}},"required":["type"]}"#)), "List of entity definitions to create."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"entities_create","entities":[{"type":"Line","start":[0,0],"end":[10,10]}]}"#,
|
||||
};
|
||||
|
||||
pub static ENTITIES_DELETE: OpDef = OpDef {
|
||||
name: "entities_delete",
|
||||
doc: "Delete entities by their handles.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("handles", Ty::Handles, "Handles of entities to delete."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"entities_delete","handles":["2A"]}"#,
|
||||
};
|
||||
|
||||
pub static ENTITIES_TRANSFORM: OpDef = OpDef {
|
||||
name: "entities_transform",
|
||||
doc: "Move, copy, rotate, scale, mirror or array entities.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("handles", Ty::Handles, "Entities to transform."),
|
||||
opt("action", Ty::EnumCi(&["move", "copy", "rotate", "scale", "mirror", "array"]), "Transform action (defaults to move)."),
|
||||
opt("vector", Ty::Point, "move/copy: displacement [dx, dy] or [dx, dy, dz]."),
|
||||
opt("center", Ty::Point, "rotate/scale: base point."),
|
||||
opt("angle_deg", Ty::Num, "rotate: angle in degrees CCW."),
|
||||
opt("factor", Ty::Num, "scale: non-zero scale factor."),
|
||||
opt("axis", Ty::ArrayOf(&Ty::Point), "mirror: axis as two points [[x1, y1], [x2, y2]]."),
|
||||
opt("copy", Ty::Bool, "mirror/copy: keep originals and return new copies."),
|
||||
opt("rows", Ty::Int, "array: row count (1..100, defaults to 1)."),
|
||||
opt("columns", Ty::Int, "array: column count (1..100, defaults to 1)."),
|
||||
opt("row_spacing", Ty::Num, "array: row spacing."),
|
||||
opt("column_spacing", Ty::Num, "array: column spacing."),
|
||||
],
|
||||
rules: &[
|
||||
Rule::RequiredWhen { key: "action", equals: "scale", then: &["factor"] },
|
||||
Rule::RequiredWhen { key: "action", equals: "mirror", then: &["axis"] },
|
||||
],
|
||||
example: r#"{"op":"entities_transform","handles":["2A"],"action":"move","vector":[10,0]}"#,
|
||||
};
|
||||
|
||||
pub static TEXT_REPLACE: OpDef = OpDef {
|
||||
name: "text_replace",
|
||||
doc: "Find and replace text in drawing text entities. Give find (+ optional replace) for one pair, or pairs for several.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("find", Ty::Str, "Text to find."),
|
||||
opt("replace", Ty::Str, "Replacement text. Defaults to empty string (deletes the match)."),
|
||||
opt("pairs", Ty::ArrayOf(&Ty::Raw(
|
||||
r#"{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find"]}"#
|
||||
)), "Several find/replace pairs applied in order."),
|
||||
opt("match_case", Ty::Bool, "Case-sensitive search."),
|
||||
opt("whole_word", Ty::Bool, "Match only whole words."),
|
||||
opt("ignore_accents", Ty::Bool, "Ignore accents/diacritics (defaults to true when match_case is false)."),
|
||||
opt("dry_run", Ty::Bool, "Report matches without changing anything."),
|
||||
opt("replace_all", Ty::Bool, "Replace all occurrences within matching entities (defaults to true)."),
|
||||
opt("scope", Ty::Enum(&["all", "active_space", "model_space", "blocks"]), "Search scope."),
|
||||
opt("layer", Ty::Str, "Optional layer name filter."),
|
||||
opt("handles", Ty::Handles, "Optional handles filter."),
|
||||
opt("type", Ty::Str, "Optional entity type filter."),
|
||||
opt("bounds", Ty::Window, "Optional bounding box filter [min_x, min_y, max_x, max_y]."),
|
||||
],
|
||||
rules: &[Rule::AnyOf(&["find", "pairs"])],
|
||||
example: r#"{"op":"text_replace","find":"OLD","replace":"NEW"}"#,
|
||||
};
|
||||
|
||||
pub static BLOCK_DEFINE: OpDef = OpDef {
|
||||
name: "block_define",
|
||||
doc: "Define a block from existing entities and place an Insert reference.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Block name."),
|
||||
req("handles", Ty::Handles, "Entities that form the block."),
|
||||
req("base", Ty::Point, "Block origin point."),
|
||||
opt("insert_at", Ty::Point, "Placement coordinates for the replacement Insert reference (defaults to base)."),
|
||||
opt("replace", Ty::Bool, "Overwrite an existing block definition of the same name."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"block_define","name":"DOOR_90","handles":["2A","2B"],"base":[0,0]}"#,
|
||||
};
|
||||
|
||||
pub static BLOCK_DELETE: OpDef = OpDef {
|
||||
name: "block_delete",
|
||||
doc: "Delete a block definition and all its child entities and inserts.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Block name to delete."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"block_delete","name":"DOOR"}"#,
|
||||
};
|
||||
|
||||
pub static XDATA_SET: OpDef = OpDef {
|
||||
name: "xdata_set",
|
||||
doc: "Attach or clear extended entity data (XData) for a registered application.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("app", Ty::Str, "Application name."),
|
||||
req("handles", Ty::Handles, "Target entity handles."),
|
||||
opt("data", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}"#)), "Typed XData entries [{code, value}]. Empty list removes app record."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"xdata_set","app":"APP","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#,
|
||||
};
|
||||
|
||||
pub static VIEW_FOCUS: OpDef = OpDef {
|
||||
name: "view_focus",
|
||||
doc: "Focus the viewport camera on the bounds of specific entities.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("handles", Ty::Handles, "Entities to focus on."),
|
||||
opt("highlight", Ty::Bool, "Also select the entities (defaults to true)."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"view_focus","handles":["2A"]}"#,
|
||||
};
|
||||
|
||||
pub static ENTITIES_COPY_TO: OpDef = OpDef {
|
||||
name: "entities_copy_to",
|
||||
doc: "Copy entities from the current document into another open document.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("handles", Ty::Handles, "Entities in the current active document to copy."),
|
||||
opt("target_document_id", Ty::Int, "Target document ID to receive the copied entities."),
|
||||
opt("document_id", Ty::Int, "Deprecated alias for target_document_id."),
|
||||
],
|
||||
rules: &[Rule::AnyOf(&["target_document_id", "document_id"])],
|
||||
example: r#"{"op":"entities_copy_to","handles":["2A"],"target_document_id":2}"#,
|
||||
};
|
||||
|
||||
pub static GROUP_CREATE: OpDef = OpDef {
|
||||
name: "group_create",
|
||||
doc: "Create a named entity group.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Group name."),
|
||||
req("handles", Ty::Handles, "Entities in the group."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"group_create","name":"Group1","handles":["2A"]}"#,
|
||||
};
|
||||
|
||||
pub static SELECTION_SET_SAVE: OpDef = OpDef {
|
||||
name: "selection_set_save",
|
||||
doc: "Save an entity handle set under a name for later recall.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Selection set name."),
|
||||
req("handles", Ty::Handles, "Entities to save."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"selection_set_save","name":"Sel1","handles":["2A"]}"#,
|
||||
};
|
||||
|
||||
pub static SELECTION_SET_LOAD: OpDef = OpDef {
|
||||
name: "selection_set_load",
|
||||
doc: "Recall a previously saved selection set by name.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Selection set name."),
|
||||
opt("select", Ty::Bool, "Also select the recalled entities in the viewport (defaults to true)."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"selection_set_load","name":"Sel1"}"#,
|
||||
};
|
||||
|
||||
pub static USER_SELECT: OpDef = OpDef {
|
||||
name: "user_select",
|
||||
doc: "Prompt the user at the desktop screen to interactively pick entities.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("prompt", Ty::Str, "Prompt text pinned on screen while user picks."),
|
||||
opt("detail", Ty::EnumCi(&["summary", "geometry", "full"]), "Returned entity detail level (defaults to full)."),
|
||||
opt("type", Ty::Str, "Entity type filter."),
|
||||
opt("layer", Ty::Str, "Layer filter."),
|
||||
opt("clear", Ty::Bool, "Clear current selection before pick begins (defaults to true)."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"user_select","prompt":"Pick entities"}"#,
|
||||
};
|
||||
|
||||
pub static GETPOINT: OpDef = OpDef {
|
||||
name: "getpoint",
|
||||
doc: "Prompt the user at the desktop screen to click one point in the viewport.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("prompt", Ty::Str, "Prompt text displayed while waiting for click."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"getpoint","prompt":"Click start point"}"#,
|
||||
};
|
||||
|
||||
pub static CLOSE: OpDef = OpDef {
|
||||
name: "close",
|
||||
doc: "Close the active drawing document.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("discard", Ty::Bool, "Erase unsaved changes instead of refusing a dirty document."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"close"}"#,
|
||||
};
|
||||
|
||||
pub static SYSVAR: OpDef = OpDef {
|
||||
name: "sysvar",
|
||||
doc: "Read or write CAD system variables (ltscale, pdmode, pdsize, clayer, etc.).",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("get", Ty::ArrayOf(&Ty::Str), "List of variable names to read."),
|
||||
opt("set", Ty::Raw(r#"{"type":"object","description":"Variable name=value pairs to set"}"#), "Key-value map of variables to set in one undo transaction."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"sysvar","get":["LTSCALE"]}"#,
|
||||
};
|
||||
|
||||
pub static LAYOUT_CREATE: OpDef = OpDef {
|
||||
name: "layout_create",
|
||||
doc: "Create a new paper-space layout tab.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("name", Ty::Str, "Layout tab name."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"layout_create","name":"Sheet1"}"#,
|
||||
};
|
||||
|
||||
pub static PAGE_SETUP_SET: OpDef = OpDef {
|
||||
name: "page_setup_set",
|
||||
doc: "Configure page setup / print settings for a layout.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("layout", Ty::Str, "Target layout name."),
|
||||
opt("paper", Ty::Str, "Paper size name."),
|
||||
opt("orientation", Ty::EnumCi(&["Portrait", "Landscape"]), "Paper orientation."),
|
||||
opt("fit", Ty::Bool, "Fit to paper (defaults to true)."),
|
||||
opt("scale", Ty::Str, "Paper-to-drawing scale ratio."),
|
||||
opt("center", Ty::Bool, "Center drawing on sheet (defaults to true)."),
|
||||
opt("offset_x", Ty::Num, "X offset in mm."),
|
||||
opt("offset_y", Ty::Num, "Y offset in mm."),
|
||||
opt("plot_style", Ty::Str, "CTB file path or named style."),
|
||||
opt("area", Ty::EnumCi(&["extents", "display", "limits", "layout", "window"]), "Plot area."),
|
||||
opt("window", Ty::Window, "World [x0, y0, x1, y1] rectangle; required when area=window."),
|
||||
],
|
||||
rules: &[Rule::RequiredWhen { key: "area", equals: "window", then: &["window"] }],
|
||||
example: r#"{"op":"page_setup_set","layout":"Sheet1","paper":"A4"}"#,
|
||||
};
|
||||
|
||||
pub static FILE_IDENTITY: OpDef = OpDef {
|
||||
name: "file_identity",
|
||||
doc: "Inspect or refresh internal drawing UUIDs and fingerprint identity.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("renew", Ty::Bool, "Generate fresh document identity tokens."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"file_identity"}"#,
|
||||
};
|
||||
|
||||
pub static SAVE: OpDef = OpDef {
|
||||
name: "save",
|
||||
doc: "Save the drawing to disk, optionally changing format or CAD version.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
opt("path", Ty::Str, "Destination file path."),
|
||||
opt("target_format", Ty::Enum(&["dwg", "dxf"]), "Target CAD format."),
|
||||
opt("target_version", Ty::Enum(TARGET_VERSIONS), "Target AutoCAD version."),
|
||||
opt("allow_lossy", Ty::Bool, "Acknowledge dropping unsupported passthrough records."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"save"}"#,
|
||||
};
|
||||
|
||||
pub static SAVE_VERIFIED: OpDef = OpDef {
|
||||
name: "save_verified",
|
||||
doc: "Save to an explicit path, reload and verify roundtrip semantic equality.",
|
||||
batchable: false,
|
||||
params: &[
|
||||
req("path", Ty::Str, "Destination file path (.dwg or .dxf)."),
|
||||
opt("target_format", Ty::Enum(&["dwg", "dxf"]), "Explicit output format matching extension."),
|
||||
opt("target_version", Ty::Enum(TARGET_VERSIONS), "Explicit CAD version."),
|
||||
opt("allow_lossy", Ty::Bool, "Acknowledge dropping unsupported records."),
|
||||
opt("overwrite", Ty::Bool, "Replace destination if file already exists."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"save_verified","path":"drawing.dwg"}"#,
|
||||
};
|
||||
|
||||
pub static STOP: OpDef = OpDef {
|
||||
name: "stop",
|
||||
doc: "Irreversible kill switch: disables all automation mutations for the session until manually re-enabled in the desktop GUI.",
|
||||
batchable: false,
|
||||
params: &[],
|
||||
rules: &[],
|
||||
example: r#"{"op":"stop"}"#,
|
||||
};
|
||||
|
||||
pub static BATCH: OpDef = OpDef {
|
||||
name: "batch",
|
||||
doc: "Execute sequential operations with fresh state and step idempotency keys. Stops on first error.",
|
||||
batchable: false,
|
||||
params: &[
|
||||
req("steps", Ty::Raw(r##"{"type":"array","minItems":1,"maxItems":64,"description":"Sequential operations list","items":{"$ref":"#/$defs/batch_step"}}"##), "List of batch steps to execute."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"batch","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#,
|
||||
};
|
||||
|
||||
pub static OPS: &[&OpDef] = &[
|
||||
&NEW,
|
||||
&OPEN,
|
||||
&ACTIVATE,
|
||||
&RUN,
|
||||
&START,
|
||||
&INPUT,
|
||||
&CANCEL,
|
||||
&UNDO,
|
||||
&REDO,
|
||||
&SELECT,
|
||||
&PROPERTY,
|
||||
&SET_PROPERTIES,
|
||||
&ACTION,
|
||||
&EMBED_IMAGE,
|
||||
&WBLOCK,
|
||||
&PLOT,
|
||||
&ENTITIES_CREATE,
|
||||
&ENTITIES_DELETE,
|
||||
&ENTITIES_TRANSFORM,
|
||||
&TEXT_REPLACE,
|
||||
&BLOCK_DEFINE,
|
||||
&BLOCK_DELETE,
|
||||
&XDATA_SET,
|
||||
&VIEW_FOCUS,
|
||||
&ENTITIES_COPY_TO,
|
||||
&GROUP_CREATE,
|
||||
&SELECTION_SET_SAVE,
|
||||
&SELECTION_SET_LOAD,
|
||||
&USER_SELECT,
|
||||
&GETPOINT,
|
||||
&CLOSE,
|
||||
&SYSVAR,
|
||||
&LAYOUT_CREATE,
|
||||
&PAGE_SETUP_SET,
|
||||
&FILE_IDENTITY,
|
||||
&SAVE,
|
||||
&SAVE_VERIFIED,
|
||||
&STOP,
|
||||
&BATCH,
|
||||
];
|
||||
|
||||
pub fn find_op(name: &str) -> Option<&'static OpDef> {
|
||||
OPS.iter().copied().find(|op| op.name == name)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA EMITTERS
|
||||
// ============================================================================
|
||||
|
||||
fn ty_schema(ty: &Ty) -> Value {
|
||||
match ty {
|
||||
Ty::Str => json!({"type": "string"}),
|
||||
Ty::Bool => json!({"type": "boolean"}),
|
||||
Ty::Num => json!({"type": "number"}),
|
||||
Ty::Int => json!({"type": "integer"}),
|
||||
Ty::Point => json!({"$ref": "#/$defs/point"}),
|
||||
Ty::Handle => json!({"$ref": "#/$defs/handle"}),
|
||||
Ty::Handles => json!({"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/handle"}}),
|
||||
Ty::Window => json!({"$ref": "#/$defs/window"}),
|
||||
Ty::Enum(v) | Ty::EnumCi(v) => json!({"type": "string", "enum": v}),
|
||||
Ty::ActionName => {
|
||||
let names = crate::app::automation_action_names();
|
||||
json!({"type": "string", "enum": names})
|
||||
}
|
||||
Ty::ArrayOf(inner) => json!({"type": "array", "items": ty_schema(inner)}),
|
||||
Ty::Raw(s) => serde_json::from_str(s).expect("Raw fragment must be valid JSON"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shared_defs() -> Value {
|
||||
let batch_variants: Vec<Value> = OPS
|
||||
.iter()
|
||||
.filter(|op| op.batchable)
|
||||
.map(|op| op_schema(op))
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"point": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3},
|
||||
"window": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4},
|
||||
"handle": {"type": "string", "pattern": "^(0[xX])?[0-9A-Fa-f]+$"},
|
||||
"batch_step": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string"},
|
||||
"document_id": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["op"],
|
||||
"anyOf": batch_variants
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn rules_text(op: &OpDef) -> String {
|
||||
let mut out = String::new();
|
||||
for r in op.rules {
|
||||
match r {
|
||||
Rule::AnyOf(keys) => {
|
||||
out.push_str(&format!(" Requires at least one of: {}.", keys.join(", ")));
|
||||
}
|
||||
Rule::RequiredWhen { key, equals, then } => {
|
||||
out.push_str(&format!(" When {key}={equals}, requires: {}.", then.join(", ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn op_schema(op: &OpDef) -> Value {
|
||||
let mut props = Map::new();
|
||||
props.insert("op".into(), json!({"enum": [op.name]}));
|
||||
for p in op.params {
|
||||
let mut s = ty_schema(&p.ty);
|
||||
if !p.doc.is_empty() {
|
||||
s["description"] = json!(p.doc);
|
||||
}
|
||||
props.insert(p.name.into(), s);
|
||||
}
|
||||
let mut required = vec!["op"];
|
||||
required.extend(op.params.iter().filter(|p| p.required).map(|p| p.name));
|
||||
json!({
|
||||
"type": "object",
|
||||
"description": format!("{}{}", op.doc, rules_text(op)),
|
||||
"properties": props,
|
||||
"required": required,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute_request_schema() -> Value {
|
||||
let variants: Vec<Value> = OPS.iter().map(|op| op_schema(op)).collect();
|
||||
let op_names: Vec<&str> = OPS.iter().map(|op| op.name).collect();
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string", "enum": op_names, "description": "Semantic editor operation."},
|
||||
"request_id": {"type": "string", "minLength": 1, "maxLength": 128, "description": "Caller-generated idempotency key."},
|
||||
"document_id": {"type": "integer", "minimum": 0, "description": "Target document from current state."},
|
||||
"revision": {"type": "integer", "minimum": 0, "description": "Expected edit revision from current state."},
|
||||
"geometry_revision": {"type": "integer", "minimum": 0, "description": "Expected geometry revision."},
|
||||
"camera_revision": {"type": "integer", "minimum": 0, "description": "Expected camera revision."},
|
||||
"selection": {"type": "array", "items": {"$ref": "#/$defs/handle"}, "description": "Expected selected handles."},
|
||||
"steps": {"type": "array", "minItems": 1, "maxItems": 64, "description": "Sequential batch operations.", "items": {"$ref": "#/$defs/batch_step"}}
|
||||
},
|
||||
"required": ["op", "request_id"],
|
||||
"anyOf": variants,
|
||||
"$defs": shared_defs()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn batch_step_schema() -> Value {
|
||||
let batch_variants: Vec<Value> = OPS
|
||||
.iter()
|
||||
.filter(|op| op.batchable)
|
||||
.map(|op| op_schema(op))
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string"},
|
||||
"document_id": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["op"],
|
||||
"anyOf": batch_variants
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRE-DISPATCH VALIDATOR
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn check_point(val: &Value) -> bool {
|
||||
val.as_array().is_some_and(|arr| {
|
||||
(2..=3).contains(&arr.len()) && arr.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))
|
||||
})
|
||||
}
|
||||
|
||||
fn check_handle(val: &Value) -> bool {
|
||||
let Some(s) = val.as_str() else { return false };
|
||||
let hex = s.trim_start_matches("0x").trim_start_matches("0X");
|
||||
!hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn check_window(val: &Value) -> bool {
|
||||
val.as_array().is_some_and(|arr| {
|
||||
arr.len() == 4 && arr.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_type(ty: &Ty, val: &Value) -> Result<(), String> {
|
||||
match ty {
|
||||
Ty::Str => {
|
||||
if val.as_str().is_none() {
|
||||
return Err("expected string".into());
|
||||
}
|
||||
}
|
||||
Ty::Bool => {
|
||||
if val.as_bool().is_none() {
|
||||
return Err("expected boolean".into());
|
||||
}
|
||||
}
|
||||
Ty::Num => {
|
||||
if !val.as_f64().is_some_and(f64::is_finite) {
|
||||
return Err("expected finite number".into());
|
||||
}
|
||||
}
|
||||
Ty::Int => {
|
||||
if val.as_i64().is_none() && val.as_u64().is_none() {
|
||||
return Err("expected integer".into());
|
||||
}
|
||||
}
|
||||
Ty::Point => {
|
||||
if !check_point(val) {
|
||||
return Err("expected [x,y] or [x,y,z] point".into());
|
||||
}
|
||||
}
|
||||
Ty::Handle => {
|
||||
if !check_handle(val) {
|
||||
return Err("expected hexadecimal handle string".into());
|
||||
}
|
||||
}
|
||||
Ty::Handles => {
|
||||
let Some(arr) = val.as_array() else {
|
||||
return Err("expected array of handles".into());
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return Err("handles array must not be empty".into());
|
||||
}
|
||||
if !arr.iter().all(check_handle) {
|
||||
return Err("all items in handles must be valid hexadecimal handle strings".into());
|
||||
}
|
||||
}
|
||||
Ty::Window => {
|
||||
if !check_window(val) {
|
||||
return Err("expected [x0, y0, x1, y1] window array".into());
|
||||
}
|
||||
}
|
||||
Ty::Enum(allowed) => {
|
||||
let Some(s) = val.as_str() else {
|
||||
return Err("expected string".into());
|
||||
};
|
||||
if !allowed.contains(&s) {
|
||||
return Err(format!("expected one of: {}", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
Ty::EnumCi(allowed) => {
|
||||
let Some(s) = val.as_str() else {
|
||||
return Err("expected string".into());
|
||||
};
|
||||
if !allowed.iter().any(|a| a.eq_ignore_ascii_case(s)) {
|
||||
return Err(format!("expected one of: {}", allowed.join(", ")));
|
||||
}
|
||||
}
|
||||
Ty::ActionName => {
|
||||
let Some(s) = val.as_str() else {
|
||||
return Err("expected action name string".into());
|
||||
};
|
||||
if !crate::app::automation_action_names().contains(&s) {
|
||||
return Err(format!("unknown action name: '{s}'"));
|
||||
}
|
||||
}
|
||||
Ty::ArrayOf(inner) => {
|
||||
let Some(arr) = val.as_array() else {
|
||||
return Err("expected array".into());
|
||||
};
|
||||
for (idx, item) in arr.iter().enumerate() {
|
||||
validate_type(inner, item)
|
||||
.map_err(|e| format!("item {idx}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ty::Raw(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_request(
|
||||
request: &Value,
|
||||
is_batch_step: bool,
|
||||
) -> Result<ValidationResult, String> {
|
||||
let Some(obj) = request.as_object() else {
|
||||
return Err("request must be a JSON object".into());
|
||||
};
|
||||
|
||||
let Some(op_name) = obj.get("op").and_then(Value::as_str) else {
|
||||
return Err("missing 'op' string in request".into());
|
||||
};
|
||||
|
||||
let Some(op_def) = find_op(op_name) else {
|
||||
return Err(format!("unknown operation: '{op_name}'"));
|
||||
};
|
||||
|
||||
if is_batch_step && !op_def.batchable {
|
||||
return Err(format!("operation '{op_name}' cannot be used in a batch step"));
|
||||
}
|
||||
|
||||
// Check required params
|
||||
for p in op_def.params {
|
||||
if p.required {
|
||||
let val = obj.get(p.name);
|
||||
if val.is_none() || val.unwrap().is_null() {
|
||||
return Err(format!(
|
||||
"Missing {} for {}. Example request: {}",
|
||||
p.name, op_name, op_def.example
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check rules
|
||||
for r in op_def.rules {
|
||||
match r {
|
||||
Rule::AnyOf(keys) => {
|
||||
let any_present = keys.iter().any(|k| obj.get(*k).is_some_and(|v| !v.is_null()));
|
||||
if !any_present {
|
||||
return Err(format!(
|
||||
"Missing {} for {}. Example request: {}",
|
||||
keys.join(" or "),
|
||||
op_name,
|
||||
op_def.example
|
||||
));
|
||||
}
|
||||
}
|
||||
Rule::RequiredWhen { key, equals, then } => {
|
||||
let matches = obj.get(*key).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case(equals));
|
||||
if matches {
|
||||
for req_key in *then {
|
||||
let val = obj.get(*req_key);
|
||||
if val.is_none() || val.unwrap().is_null() {
|
||||
return Err(format!(
|
||||
"Missing {} for {}. Example request: {}",
|
||||
req_key, op_name, op_def.example
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate types for present parameters
|
||||
for p in op_def.params {
|
||||
if let Some(val) = obj.get(p.name) {
|
||||
if !val.is_null() {
|
||||
validate_type(&p.ty, val).map_err(|e| {
|
||||
format!("parameter '{}' for op '{}': {e}", p.name, op_name)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect unknown keys (Phase 1: warning)
|
||||
let mut warnings = Vec::new();
|
||||
let mut allowed: Vec<&str> = op_def.params.iter().map(|p| p.name).collect();
|
||||
if is_batch_step {
|
||||
allowed.extend(["op", "document_id"]);
|
||||
} else {
|
||||
allowed.extend(ENVELOPE_KEYS.iter().copied());
|
||||
}
|
||||
|
||||
for (k, _) in obj {
|
||||
if !allowed.contains(&k.as_str()) {
|
||||
if k == "data_base64" {
|
||||
warnings.push("data_base64 is supported only in the Web/WASM build, not in native desktop".into());
|
||||
} else {
|
||||
warnings.push(format!("unknown parameter '{k}' ignored for op '{op_name}'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ValidationResult { warnings })
|
||||
}
|
||||
Reference in new issue
Block a user