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

+54
View File
@@ -1606,6 +1606,60 @@ mod ocs {
Ok(vm.ctx.new_list(output).into())
}
/// One step of driving a real OCS command. `kind` is `run` (`line`),
/// `start` (`name`), `point` (`point`), `text` (`text`), `token` (`text`),
/// `entity` (`handle`, `point`), `selection`, `enter` or `cancel`. Returns
/// where the command stands: `status`, `blocked_by`, `command`, `prompt`,
/// `accepts`, `options`, `entities`, `added`, `unconsumed`, `error`.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn command_step(kind: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
use ocs_plugin_api::host::CommandRequest as R;
let options = if vm.is_none(&options) {
vm.ctx.new_dict()
} else {
options.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?
};
let required = |key: &str| -> PyResult<PyObjectRef> {
options
.get_item_opt(key, vm)?
.ok_or_else(|| vm.new_value_error(format!("ocs.command_step: {kind} needs {key}")))
};
let request = match kind.as_str() {
"run" => R::Run { line: required("line")?.try_into_value::<String>(vm)? },
"start" => R::Start { name: required("name")?.try_into_value::<String>(vm)? },
"point" => R::Point { point: py_to_f64_array::<3>(required("point")?, vm)? },
"text" => R::Text { text: required("text")?.try_into_value::<String>(vm)? },
"token" => R::Token { text: required("text")?.try_into_value::<String>(vm)? },
"entity" => R::Entity {
handle: Handle::new(required("handle")?.try_into_value::<u64>(vm)?),
point: py_to_f64_array::<3>(required("point")?, vm)?,
},
"selection" => R::Selection,
"enter" => R::Enter,
"cancel" => R::Cancel,
other => return Err(vm.new_value_error(format!("ocs.command_step: unknown step {other:?}"))),
};
let outcome = host_ctx::with_host(|host| host.run_command(request))
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.map_err(|error| vm.new_runtime_error(format!("ocs.command_step: {error}")))?;
let dict = vm.ctx.new_dict();
let strings = |items: &[String]| -> PyObjectRef {
PyObjectRef::from(vm.ctx.new_list(items.iter().map(|v| vm.new_pyobj(v.clone())).collect()))
};
dict.set_item("status", vm.new_pyobj(outcome.status.clone()), vm)?;
dict.set_item("blocked_by", outcome.blocked_by.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?;
dict.set_item("command", vm.new_pyobj(outcome.command.clone()), vm)?;
dict.set_item("prompt", vm.new_pyobj(outcome.prompt.clone()), vm)?;
dict.set_item("accepts", strings(&outcome.accepts), vm)?;
dict.set_item("options", strings(&outcome.options), vm)?;
dict.set_item("entities", vm.new_pyobj(outcome.entities), vm)?;
dict.set_item("added", vm.new_pyobj(outcome.added), vm)?;
dict.set_item("unconsumed", strings(&outcome.unconsumed), vm)?;
dict.set_item("error", outcome.error.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?;
Ok(dict.into())
}
/// Every layer with its full properties, in table order.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]