feat(python): command runner and modify wrappers (doc.command, doc.modify)

HostApi::run_command drives the real OCS command through the same primitives
the automation channel uses, synchronously on the host thread: run a line,
start a command and answer point/text/token/entity/selection/enter prompts, or
cancel (closing anything the command opened). Refused while another command is
active, for a denylist (QUIT, NEW, OPEN, SAVE*, PY_* ...) and off the active tab.
Python: doc.command, doc.start_command and doc.modify (offset, trim, extend,
fillet, move, copy, rotate, scale, mirror, erase). Real-IPC audit checks exact
geometry for every wrapper plus guards and undo; IPC and Python unit tests; docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 17:25:05 +02:00
1 parent 795aa922b4
commit 1b1785eefb
13 files changed
+867 -1

No files matched your search

+57
View File
@@ -85,6 +85,56 @@ pub enum SolidBoolean {
Intersect,
}
/// One step of driving an OCS command from a script (API v7, additive). The
/// host runs the real command, so every tool behaves exactly as at the command
/// line, and answers with where the command stands.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CommandRequest {
/// Run a whole command line (`OFFSET 5 ...`): the tokens after the name
/// answer the prompts in order and a final Enter finishes the command.
Run { line: String },
/// Start a command and leave it waiting for its first input.
Start { name: String },
/// Answer the current prompt with a world-coordinate point.
Point { point: [f64; 3] },
/// Answer with typed text (a distance, an angle, a name).
Text { text: String },
/// Answer with a keyword option.
Token { text: String },
/// Pick an entity at a point.
Entity { handle: Handle, point: [f64; 3] },
/// Complete an object-selection prompt with the current selection.
Selection,
/// Press Enter.
Enter,
/// Cancel the running command and close anything it left open.
Cancel,
}
/// Where a command stands after a [`CommandRequest`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CommandOutcome {
/// `completed` when nothing is waiting, `waiting_input` when the command
/// (or an editor or dialog it opened) needs more.
pub status: String,
/// What is still open: `command`, `text_editor`, `mtext_editor` or `modal:<kind>`.
pub blocked_by: Option<String>,
/// The waiting command's name and prompt (empty when none).
pub command: String,
pub prompt: String,
/// The kinds of input the prompt accepts (`point`, `entity`, `selection`,
/// `text`, `token`, `enter`) and its keyword options.
pub accepts: Vec<String>,
pub options: Vec<String>,
/// Entities in the drawing after the step, and the change.
pub entities: u64,
pub added: i64,
/// Typed tokens no prompt asked for.
pub unconsumed: Vec<String>,
/// The command line's error message when the step failed.
pub error: Option<String>,
}
/// A change to a drawing table record (API v7, additive). The host validates
/// the request, records one undo step and refuses without changing anything
/// when it cannot honour it; the result is the record's handle.
@@ -808,6 +858,13 @@ pub trait HostApi {
Err("solid operations are not supported by this host".to_owned())
}
/// Drive an OCS command from a script (API v7, additive). Refused, with
/// nothing run, when another command is already active or the request names
/// a command that could end the session or re-enter the plugin.
fn run_command(&mut self, _request: CommandRequest) -> Result<CommandOutcome, String> {
Err("running commands is not supported by this host".to_owned())
}
/// Create, change, rename, delete or select a drawing table record (API v7,
/// additive). Returns the record's handle; a refusal changes nothing.
fn table_operation(&mut self, _operation: TableOperation) -> Result<Handle, String> {
+12
View File
@@ -482,6 +482,18 @@ impl HostApi for PluginHostApi {
}
}
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
match self.client.request(PluginRequest::RunCommand { request }) {
Ok(PluginResponse::CommandResult(result)) => {
self.document_cache = OnceCell::new();
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected command response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
match self.client.request(PluginRequest::TableOperation { operation }) {
Ok(PluginResponse::TableResult(result)) => {
@@ -157,6 +157,8 @@ pub enum PluginRequest {
SolidOperation { operation: crate::host::SolidOperation },
/// V7 (additive): drawing table record create/modify/rename/delete.
TableOperation { operation: crate::host::TableOperation },
/// V7 (additive): drive an OCS command.
RunCommand { request: crate::host::CommandRequest },
}
/// Responses the host sends back for `PluginRequest`.
@@ -192,6 +194,7 @@ pub enum PluginResponse {
SelectionResult(Result<(), String>),
SolidResult(Result<Handle, String>),
TableResult(Result<Handle, String>),
CommandResult(Result<crate::host::CommandOutcome, String>),
}
/// Messages sent from the host to the plugin runner.
+1
View File
@@ -111,5 +111,6 @@ pub fn handle_plugin_request(
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)),
TableOperation { operation } => PluginResponse::TableResult(host.table_operation(operation)),
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
}
}
@@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi {
}
}
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
match self.request(PluginRequest::RunCommand { request }) {
Ok(PluginResponse::CommandResult(result)) => {
self.document_cache = OnceCell::new();
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected command response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
match self.request(PluginRequest::TableOperation { operation }) {
Ok(PluginResponse::TableResult(result)) => {
@@ -105,6 +105,43 @@ mod tests {
assert_eq!(decoded, changed);
}
#[test]
fn command_request_and_outcome_roundtrip() {
use crate::host::{CommandOutcome, CommandRequest};
for request in [
CommandRequest::Run { line: "LINE 0,0 1,1".into() },
CommandRequest::Start { name: "OFFSET".into() },
CommandRequest::Point { point: [1.0, 2.0, 3.0] },
CommandRequest::Text { text: "2".into() },
CommandRequest::Token { text: "R".into() },
CommandRequest::Entity { handle: acadrust::Handle::new(9), point: [0.0; 3] },
CommandRequest::Selection,
CommandRequest::Enter,
CommandRequest::Cancel,
] {
let bytes = bincode::serialize(&PluginRequest::RunCommand { request: request.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::RunCommand { request: decoded } if decoded == request));
}
let outcome = CommandOutcome {
status: "waiting_input".into(),
blocked_by: Some("command".into()),
command: "OFFSET".into(),
prompt: "Select object".into(),
accepts: vec!["entity".into()],
options: vec!["M".into()],
entities: 4,
added: -1,
unconsumed: vec!["x".into()],
error: Some("e".into()),
};
for result in [Ok(outcome), Err("refused".to_owned())] {
let bytes = bincode::serialize(&PluginResponse::CommandResult(result.clone())).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::CommandResult(decoded) if decoded == result));
}
}
#[test]
fn table_operation_request_and_response_roundtrip() {
use crate::host::{LayerConfig, TableOperation};