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 <[email protected]>
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};
+20
View File
@@ -253,3 +253,23 @@ persistence; deleting the current layout; four-step undo restoring the layouts,
round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and
styles, and page-setup names. Next: headless command execution.
### 2026-09-21: command runner and modify wrappers (`doc.command`, `doc.start_command`, `doc.modify`)
`HostApi::run_command(CommandRequest) -> CommandOutcome` (additive v7; `Run`, `Start`, `Point`, `Text`, `Token`, `Entity`,
`Selection`, `Enter`, `Cancel`) drives the real command through the same primitives the automation channel uses
(`run_command_line`, `dispatch_command`, `feed_command`, `feed_active_cmd`, `CommandEscape`) and finishes each step with
`drive_headless_task`, which is synchronous on the host thread. The earlier "nested command replay can hang" concern does not
apply to this path: no step waits on anything that must arrive from outside, the exploration harness ran every command
tried without a hang, and an editor or dialog a command opens is reported in `blocked_by` and closed by `Cancel`. It refuses
while another command is active, on a denylist (`QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`,
`RUNSCRIPT`, `PY_*`), and off the active tab. It does not consult the user's automation on/off switch, which governs the
external MCP/serve channel; a script is already something the user chose to run. Python: `doc.command`, `doc.start_command`
(context manager that cancels a waiting command) and `doc.modify` (offset, trim, extend, fillet, move, copy, rotate, scale,
mirror, erase). The prompt sequences were observed with `spike_command_runner_prompts` (ignored harness). Evidence:
`audit_python_modify_wrappers_over_real_ipc` checks exact geometry for every wrapper (offset distance and side, trim and
extend end points, fillet arc centre and radius, move, copy keeping the original, rotate, scale, mirror keeping the source,
erase), `doc.command` and an interactive session, nine refusals (quit, new, `PY_RUN`, unknown, incomplete line, input with no
command, second command, run while active), no command left running, and a one-step undo. IPC round-trip and Python unit tests.
Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and
`start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands.
+48
View File
@@ -258,6 +258,54 @@ layout limits. Layout operations act on the active document. Records list
`current`, `tab_order`, `entity_count`, `viewport_count`, `paper_size`, `rotation`
and `scale`. Reordering layouts and plot devices or styles are not available yet.
### Running OCS commands: `doc.command`, `doc.start_command`, `doc.modify`
Scripts can drive OCS's own tools. The host runs the real command, so it behaves
exactly as at the command line (same prompts, same geometry, same undo), and does
it synchronously on the editor thread, so a step never waits on outside input.
```python
doc = ocs.active_document
doc.command("CIRCLE 5,5 3") # a whole line: tokens answer the prompts, then Enter
with doc.start_command("OFFSET") as c: # or answer prompt by prompt
c.text(2) # a typed value
c.entity(line, at=(5, 0, 0)) # an object pick
c.point((5, 4, 0)) # a point
c.enter()
print(c.outcome["status"], c.outcome["prompt"], c.outcome["added"])
M = doc.modify # the common tools as plain calls
M.offset(line, 2, side=(5, 5)) # returns the new entity
M.trim(target, at=(108, 0)); M.extend(target, at=(208, 0))
M.fillet(first, second, radius=2) # radius 0 squares the corner
M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target)
M.rotate([a], base, 90); M.scale([a], base, 2)
M.mirror([a], p1, p2, erase_source=False); M.erase([a])
```
Each step returns an outcome dict: `status` (`completed` or `waiting_input`),
`blocked_by`, `command`, `prompt`, `accepts` (`point`, `entity`, `selection`,
`text`, `token`, `enter`), `options` (keyword tokens), `entities`, `added`,
`unconsumed` and `error`. `c.point`, `c.text`, `c.token`, `c.entity`,
`c.selection`, `c.enter` and `c.cancel` map to those prompts; an `error` raises
`RuntimeError`, and leaving the `with` block cancels a command that is still waiting,
also closing any editor or dialog it opened. `doc.command` cancels and raises when
the line leaves a prompt open.
Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`) select
the entities you pass first, so the command skips its own selection prompt. Pick
points default to the middle of a line, arc or circle; give `at=` for anything else.
`trim` and `extend` cut or extend against every entity in the drawing. Each command is
undone as the editor would undo it.
Guards: a command is refused while another is active (cancel it first) or when the
line names a command that could end the session or re-enter Python: `QUIT`,
`EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, `SAVEAS`, `SAVEALL`,
`RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active
document only. Commands that need an interactive surface a script cannot fill
(the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled.
`ocs.command_step(kind, options)` is the raw single step behind all of this.
### Historical command-replay experiment (not in the default build)
The following records an earlier experiment. The bundled build does **not**
@@ -580,6 +580,185 @@ class _Layouts(_Named):
return self[name]
def _pt(value):
"""A point as [x, y, z] from a tuple, list or {"x", "y", "z"} dict."""
if isinstance(value, dict):
return [float(value["x"]), float(value["y"]), float(value.get("z", 0.0))]
coords = [float(v) for v in value]
if len(coords) == 2:
coords.append(0.0)
if len(coords) != 3:
raise ValueError("a point needs 2 or 3 coordinates")
return coords
def _handle(value):
return value.handle if isinstance(value, _Entity) else int(value)
class _Command:
"""One running OCS command, driven a step at a time:
with doc.start_command("OFFSET") as c:
c.text(2); c.entity(line, at=(5, 0, 0)); c.point((5, 4, 0)); c.enter()
Each step returns the outcome dict (`status`, `prompt`, `accepts`,
`options`, `added`, `error`, ...) and raises `RuntimeError` when the command
reports an error. Leaving the `with` block cancels the command if it is still
waiting. The host runs the real command, refuses to start one while another
is active, and refuses commands that could end the session (QUIT, NEW, OPEN,
SAVE...) or re-enter Python (PY_*)."""
def __init__(self, name):
self.name = str(name)
self.outcome = None
self._send("start", name=self.name)
def _send(self, kind, **options):
outcome = ocs.command_step(kind, options)
self.outcome = outcome
if outcome["error"]:
raise RuntimeError("%s: %s" % (self.name, outcome["error"]))
return outcome
@property
def waiting(self):
return self.outcome is not None and self.outcome["status"] == "waiting_input"
def point(self, point):
return self._send("point", point=_pt(point))
def text(self, value):
return self._send("text", text=str(value))
def token(self, keyword):
return self._send("token", text=str(keyword))
def entity(self, entity, at):
return self._send("entity", handle=_handle(entity), point=_pt(at))
def selection(self):
"""Complete an object-selection prompt with the current selection."""
return self._send("selection")
def enter(self):
return self._send("enter")
def cancel(self):
return self._send("cancel")
def __enter__(self):
return self
def __exit__(self, *exc):
if self.waiting:
ocs.command_step("cancel", None)
return False
def _finished(command, what):
"""Require that `command` ended by itself; cancel and raise if it did not."""
if command.waiting:
prompt = command.outcome["prompt"]
command.cancel()
raise RuntimeError("%s did not finish: still asking %r" % (what, prompt))
class _Modify:
"""Modify tools as plain calls. Each drives the real OCS command, so it
behaves exactly as at the command line, and returns the entities it added.
A tool that needs a pick point takes `at=` (a point on or near the entity);
without it the middle of a line, arc or circle is used."""
def __init__(self, document):
self._document = document
def _at(self, entity, at):
if at is not None:
return _pt(at)
samples = ocs.curve_samples(_handle(entity), 4)
if samples is None:
raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known")
middle = samples["vertices"][len(samples["vertices"]) // 2]
return [middle[0], middle[1], samples["elevation"]]
def _select(self, entities):
ocs.select([_handle(e) for e in entities])
def _added(self, before):
before = set(before)
return [self._document.entities[h] for h in ocs.entity_handles() if h not in before]
def _run(self, name, steps, entities=None):
before = list(ocs.entity_handles())
if entities is not None:
self._select(entities)
with _Command(name) as command:
for step in steps:
step(command)
_finished(command, name)
return self._added(before)
def offset(self, entity, distance, side, at=None):
"""Offset one entity by `distance` towards the point `side`; returns the new entity."""
result = self._run("OFFSET", [
lambda c: c.text(distance),
lambda c: c.entity(entity, self._at(entity, at)),
lambda c: c.point(side),
lambda c: c.enter(),
])
if not result:
raise RuntimeError("OFFSET produced nothing")
return result[0]
def trim(self, target, at):
"""Trim `target` at the crossing nearest the click point `at`, against every entity in the drawing."""
return self._run("TRIM", [lambda c: c.entity(target, at), lambda c: c.enter()])
def extend(self, target, at):
"""Extend `target` from the end nearest `at` to the next entity in its way."""
return self._run("EXTEND", [lambda c: c.entity(target, at), lambda c: c.enter()])
def fillet(self, first, second, radius=0, at1=None, at2=None):
"""Round (or, with radius 0, square) the corner between two entities."""
return self._run("FILLET", [
lambda c: c.token("R"),
lambda c: c.text(radius),
lambda c: c.entity(first, self._at(first, at1)),
lambda c: c.entity(second, self._at(second, at2)),
lambda c: c.enter(),
])
def move(self, entities, base, target):
self._run("MOVE", [lambda c: c.point(base), lambda c: c.point(target)], entities)
def copy(self, entities, base, target):
"""Copy the entities once from `base` to `target`; returns the copies."""
return self._run("COPY", [lambda c: c.point(base), lambda c: c.point(target), lambda c: c.enter()], entities)
def rotate(self, entities, base, angle):
"""Rotate about `base` by `angle` degrees."""
self._run("ROTATE", [lambda c: c.point(base), lambda c: c.text(angle)], entities)
def scale(self, entities, base, factor):
self._run("SCALE", [lambda c: c.point(base), lambda c: c.text(factor)], entities)
def mirror(self, entities, first, second, erase_source=False):
"""Mirror about the line through two points; returns the mirrored copies."""
return self._run("MIRROR", [
lambda c: c.point(first),
lambda c: c.point(second),
lambda c: c.token("Y" if erase_source else "N"),
], entities)
def erase(self, entities):
before = len(list(ocs.entity_handles()))
self._select(entities)
with _Command("ERASE") as command:
_finished(command, "ERASE")
return before - len(list(ocs.entity_handles()))
class _Document:
def __init__(self):
self._pending = None
@@ -589,9 +768,29 @@ class _Document:
self.blocks = _Blocks()
self.linetypes = _Linetypes()
self.layouts = _Layouts()
self.modify = _Modify(self)
self.entities = _Entities(self)
self.solids = _Solids(self)
def command(self, line):
"""Run one whole OCS command line, e.g. `"CIRCLE 5,5 3"`; the tokens after
the name answer the prompts in order and a final Enter finishes. Returns
the outcome dict. If the line leaves the command waiting for more input the
command is cancelled and `RuntimeError` names the prompt; use
`start_command` to answer prompts one at a time."""
outcome = ocs.command_step("run", {"line": str(line)})
if outcome["error"]:
raise RuntimeError("%s: %s" % (line, outcome["error"]))
if outcome["status"] != "completed":
prompt = outcome["prompt"] or outcome["blocked_by"]
ocs.command_step("cancel", None)
raise RuntimeError("%r did not finish: still asking %r" % (line, prompt))
return outcome
def start_command(self, name):
"""Start a command and answer its prompts step by step (see `_Command`)."""
return _Command(name)
def transaction(self, label):
return _Transaction(self, label)
+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]
@@ -46,11 +46,58 @@ class DocumentModelTests(unittest.TestCase):
linetype_operation=lambda op, name, options: self.calls.append(("linetype", op, name, options)) or 9,
layout_records=lambda: [{"name": n, "current": n == "Model"} for n in ("Model", "Sheet1")],
layout_operation=lambda op, name, options: self.calls.append(("layout", op, name, options)) or 9,
command_step=self._command_step,
curve_samples=lambda handle, n: {"vertices": [[0, 0], [1, 0], [2, 0]], "elevation": 5.0, "closed": False},
)
namespace = {"ocs": self.ocs}
exec(MODEL.read_text(), namespace)
self.doc = self.ocs.active_document
def _command_step(self, kind, options):
self.calls.append(("step", kind, options))
outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [],
"options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None}
outcome.update(self.step_outcome.get(kind, {}))
if kind == "enter":
new = max(self.records) + 1
self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True}
return outcome
def test_command_runner(self):
self.step_outcome = {}
out = self.doc.command("CIRCLE 0,0 5")
self.assertEqual(self.calls[-1], ("step", "run", {"line": "CIRCLE 0,0 5"}))
self.assertEqual(out["status"], "completed")
self.step_outcome = {"run": {"status": "waiting_input", "prompt": "Select object"}}
with self.assertRaises(RuntimeError):
self.doc.command("OFFSET")
self.assertEqual(self.calls[-1], ("step", "cancel", None))
self.step_outcome = {"run": {"error": "bad"}}
with self.assertRaises(RuntimeError):
self.doc.command("X")
waiting = {"status": "waiting_input"}
self.step_outcome = {"start": waiting, "point": waiting, "entity": waiting}
with self.doc.start_command("OFFSET") as command:
command.point((1, 2))
self.assertEqual(self.calls[-1], ("step", "point", {"point": [1.0, 2.0, 0.0]}))
command.entity(self.doc.entities[1], at=(0, 0, 0))
self.assertEqual(self.calls[-1][2]["handle"], 1)
self.assertEqual(self.calls[-1], ("step", "cancel", None))
def test_modify_wrappers(self):
self.step_outcome = {}
self.doc.modify.trim(1, (3, 0, 0))
steps = [c for c in self.calls if c[0] == "step"]
self.assertEqual([c[1] for c in steps], ["start", "entity", "enter"])
self.calls.clear()
self.doc.modify.move([1, 2], (0, 0, 0), (5, 5, 0))
self.assertEqual(self.calls[0], ("selection", [1, 2]))
self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point"])
self.calls.clear()
self.doc.modify.offset(1, 2, (0, 5))
entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0]
self.assertEqual(entity_step[2]["point"], [1, 0, 5.0])
def test_batch_edit_and_rollback(self):
line = self.doc.entities[1]
self.assertEqual(line.start, (1, 2, 3))
+1 -1
View File
@@ -314,7 +314,7 @@ fn command_selection_policy(name: &str) -> &'static str {
}
}
fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
let options = command.options();
let mut accepts = Vec::new();
if command.is_selection_gathering() {
+376
View File
@@ -1273,6 +1273,178 @@ impl<'a> HostSession<'a> {
}
}
/// Drive a real OCS command one step at a time (see
/// `ocs_plugin_api::host::CommandRequest`). The same primitives the
/// automation channel uses, run synchronously on this thread, so a step
/// never waits on anything that has to arrive from outside.
pub fn run_command(
&mut self,
request: ocs_plugin_api::host::CommandRequest,
) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
use crate::command::StepInput;
use ocs_plugin_api::host::CommandRequest as R;
if self.tab != self.app.active_tab {
return Err("commands act on the active document; switch to it first".to_owned());
}
let tab = self.tab;
let refuse = |line: &str| -> Option<String> {
let first = line.split_whitespace().next()?.trim_start_matches(['\'', '_']).to_uppercase();
const DENIED: &[&str] = &[
"QUIT", "EXIT", "CLOSE", "CLOSEALL", "NEW", "QNEW", "OPEN", "SAVE", "QSAVE", "SAVEAS",
"SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT",
];
if DENIED.contains(&first.as_str()) || first.starts_with("PY_") {
Some(format!("the command {first} cannot be run from a script"))
} else {
None
}
};
let active = self.app.tabs[tab].active_cmd.is_some();
let before = self.document().entities().count() as i64;
let error_revision = self.app.command_line.error_revision;
let editor_before = self.app.text_inline.is_some();
let mtext_before = self.app.mtext_editor.is_some();
let modal_before = self.app.active_modal.is_some();
self.app.command_line.unconsumed.clear();
let task = match request {
R::Run { line } => {
if line.trim().is_empty() {
return Err("a command line is empty".to_owned());
}
if let Some(reason) = refuse(&line) {
return Err(reason);
}
if active {
return Err("a command is already active; cancel it first".to_owned());
}
self.app.run_command_line(&line)
}
R::Start { name } => {
if name.trim().is_empty() || name.split_whitespace().count() != 1 {
return Err("a command name is one word".to_owned());
}
if let Some(reason) = refuse(&name) {
return Err(reason);
}
if active {
return Err("a command is already active; cancel it first".to_owned());
}
self.app.dispatch_command(name.trim())
}
R::Cancel => {
let task = self.app.update(crate::app::Message::CommandEscape);
self.app.drive_headless_task(task)?;
// Close what the command opened: an editor or a dialog would leave
// the interface waiting on a script that has moved on.
if !editor_before {
self.app.text_inline = None;
}
if !mtext_before {
self.app.mtext_editor = None;
}
if !modal_before {
self.app.active_modal = None;
}
return Ok(self.command_outcome(before, None));
}
other => {
if !active {
return Err("no command is waiting for input".to_owned());
}
let input = match other {
R::Point { point } => {
if point.iter().any(|v| !v.is_finite()) {
return Err("a point must be finite".to_owned());
}
StepInput::Point(glam::DVec3::new(point[0], point[1], point[2]))
}
R::Text { text } => StepInput::Text(text),
R::Token { text } => {
let task = self.app.feed_active_cmd(&text);
self.app.drive_headless_task(task)?;
return Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before));
}
R::Entity { handle, point } => {
if point.iter().any(|v| !v.is_finite()) {
return Err("a point must be finite".to_owned());
}
if self.document().get_entity(handle).is_none() {
return Err(format!("entity {} does not exist", handle.value()));
}
StepInput::EntityPick(handle, glam::DVec3::new(point[0], point[1], point[2]))
}
R::Selection => StepInput::SelectionComplete(self.app.tabs[tab].scene.selected_handles_in_order()),
R::Enter => StepInput::Enter,
R::Run { .. } | R::Start { .. } | R::Cancel => unreachable!(),
};
self.app.feed_command(input)
}
};
self.app.drive_headless_task(task)?;
Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before))
}
fn finish_command_step(
&mut self,
before: i64,
error_revision: u64,
editor_before: bool,
mtext_before: bool,
modal_before: bool,
) -> ocs_plugin_api::host::CommandOutcome {
let error = (self.app.command_line.error_revision != error_revision)
.then(|| self.app.command_line.last_error.clone().unwrap_or_default());
let blocked = if self.app.tabs[self.tab].active_cmd.is_some() {
Some("command".to_owned())
} else if let (false, Some(modal)) = (modal_before, self.app.active_modal.as_ref()) {
Some(format!("modal:{modal:?}"))
} else if !editor_before && self.app.text_inline.is_some() {
Some("text_editor".to_owned())
} else if !mtext_before && self.app.mtext_editor.is_some() {
Some("mtext_editor".to_owned())
} else {
None
};
self.set_dirty();
self.publish_document_view();
let mut outcome = self.command_outcome(before, blocked);
outcome.error = error;
outcome
}
fn command_outcome(&self, before: i64, blocked_by: Option<String>) -> ocs_plugin_api::host::CommandOutcome {
let now = self.document().entities().count() as i64;
let metadata = self.app.tabs[self.tab]
.active_cmd
.as_deref()
.map(crate::app::control::active_command_metadata);
let text = |key: &str| metadata.as_ref().and_then(|m| m[key].as_str()).unwrap_or("").to_owned();
let list = |key: &str| -> Vec<String> {
metadata
.as_ref()
.and_then(|m| m[key].as_array())
.map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default()
};
let options = metadata
.as_ref()
.and_then(|m| m["options"].as_array())
.map(|items| items.iter().filter_map(|o| o["keyword"].as_str().filter(|k| !k.is_empty()).map(str::to_owned)).collect())
.unwrap_or_default();
ocs_plugin_api::host::CommandOutcome {
status: if blocked_by.is_some() { "waiting_input" } else { "completed" }.to_owned(),
blocked_by,
command: text("name"),
prompt: text("prompt"),
accepts: list("accepts"),
options,
entities: now as u64,
added: now - before,
unconsumed: self.app.command_line.unconsumed.clone(),
error: None,
}
}
/// Simple linetype create/modify/rename/delete.
fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result<Handle, String> {
use ocs_plugin_api::host::TableOperation;
@@ -2367,6 +2539,9 @@ impl HostApi for HostSession<'_> {
fn table_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result<Handle, String> {
self.table_operation(operation)
}
fn run_command(&mut self, request: ocs_plugin_api::host::CommandRequest) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
self.run_command(request)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
@@ -7830,6 +8005,207 @@ LT.delete('Temp')
assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle");
}
/// The modify wrappers through Python over the real runner: each drives
/// the real OCS command and the resulting geometry is checked exactly.
#[test]
fn audit_python_modify_wrappers_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
return;
};
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
).unwrap();
let dir = std::env::temp_dir();
let run = |host: &mut HostSession<'_>, tag: &str, body: &str| {
let script = dir.join(format!("ocs_modify_{tag}_{}.py", std::process::id()));
std::fs::write(&script, format!(concat!(
"def P(x, y, z=0.0): return {{'x': x, 'y': y, 'z': z}}\n",
"doc = ocs.active_document\nM = doc.modify\nL = doc.layers\n",
"refused, failed = [], []\n",
"def check(tag, fn):\n",
" try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n",
" else:\n failed.append('ACCEPTED_' + tag)\n",
"def step(tag, fn):\n",
" try:\n return fn()\n except Exception as error:\n",
" failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:70])\n",
"{body}\n",
"L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))\n",
), body = body)).unwrap();
assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap());
let _ = std::fs::remove_file(&script);
};
let report = |document: &CadDocument| -> (usize, Vec<String>) {
let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last()
.expect("script report layer");
let (refused, failed) = name["REPORT".len()..].split_once('~').unwrap();
(refused.trim().parse().unwrap(), failed.split_whitespace().map(str::to_owned).collect())
};
run(&mut host, "build", r#"
def line(a, b): return doc.create_entity('Line', start=P(*a), end=P(*b))
def circle(c, r): return doc.create_entity('Circle', center=P(*c), radius=r)
a = line((0, 0), (10, 0))
step('offset', lambda: M.offset(a, 2, (5, 5)))
h = line((100, 0), (110, 0)); v = line((105, -5), (105, 5))
step('trim', lambda: M.trim(h, (108, 0)))
h2 = line((200, 0), (209, 0)); w = line((215, -5), (215, 5))
step('extend', lambda: M.extend(h2, (208, 0)))
f1 = line((300, 0), (310, 0)); f2 = line((300, 0), (300, 10))
step('fillet', lambda: M.fillet(f1, f2, 2, (308, 0), (300, 8)))
m = circle((400, 0), 1)
step('move', lambda: M.move([m], (0, 0, 0), (5, 5, 0)))
c = circle((500, 0), 1)
step('copy', lambda: M.copy([c], (0, 0, 0), (0, 10, 0)))
r = line((600, 0), (610, 0))
step('rotate', lambda: M.rotate([r], (600, 0, 0), 90))
sc = line((700, 0), (710, 0))
step('scale', lambda: M.scale([sc], (700, 0, 0), 2))
mi = line((800, 0), (810, 0))
step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0)))
e = line((900, 0), (910, 0))
step('erase', lambda: M.erase([e]))
step('run_line', lambda: doc.command('LINE 1100,0 1110,10'))
def interactive():
with doc.start_command('CIRCLE') as c:
c.point((1200, 0, 0))
c.text(3)
if c.waiting:
raise RuntimeError('circle still waiting')
step('interactive', interactive)
check('quit', lambda: doc.start_command('QUIT'))
check('newdoc', lambda: doc.command('NEW'))
check('py', lambda: doc.command('PY_RUN nothing'))
check('unknown', lambda: doc.start_command('NOSUCHCOMMAND'))
check('incomplete', lambda: doc.command('OFFSET'))
check('input_without_command', lambda: ocs.command_step('point', {'point': [0, 0, 0]}))
open_command = doc.start_command('LINE')
check('second_command', lambda: doc.start_command('CIRCLE'))
check('run_while_active', lambda: doc.command('POINT 1,1'))
open_command.cancel()
check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]}))
"#);
let (refused, failed) = report(host.document());
assert!(failed.is_empty(), "wrapper failures: {failed:?}");
assert_eq!(refused, 9);
let lines: Vec<acadrust::entities::Line> = host.document().entities().filter_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).collect();
let circles: Vec<acadrust::entities::Circle> = host.document().entities().filter_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).collect();
let arcs: Vec<acadrust::entities::Arc> = host.document().entities().filter_map(|e| match e { EntityType::Arc(a) => Some(a.clone()), _ => None }).collect();
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let has_line = |x1: f64, y1: f64, x2: f64, y2: f64| lines.iter().any(|l| {
(near(l.start.x, x1) && near(l.start.y, y1) && near(l.end.x, x2) && near(l.end.y, y2))
|| (near(l.start.x, x2) && near(l.start.y, y2) && near(l.end.x, x1) && near(l.end.y, y1))
});
let describe = || lines.iter().map(|l| format!("({},{})-({},{})", l.start.x, l.start.y, l.end.x, l.end.y)).collect::<Vec<_>>();
assert!(has_line(0.0, 0.0, 10.0, 0.0) && has_line(0.0, 2.0, 10.0, 2.0), "offset: {:?}", describe());
assert!(has_line(100.0, 0.0, 105.0, 0.0), "trim: {:?}", describe());
assert!(has_line(200.0, 0.0, 215.0, 0.0), "extend: {:?}", describe());
assert!(arcs.iter().any(|a| near(a.radius, 2.0) && near(a.center.x, 302.0) && near(a.center.y, 2.0)), "fillet arc: {arcs:?}");
assert!(circles.iter().any(|c| near(c.center.x, 405.0) && near(c.center.y, 5.0)), "move: {circles:?}");
assert!(!circles.iter().any(|c| near(c.center.x, 400.0)), "move left the original: {circles:?}");
assert!(circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 0.0)) && circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 10.0)), "copy: {circles:?}");
assert!(has_line(600.0, 0.0, 600.0, 10.0), "rotate: {:?}", describe());
assert!(has_line(700.0, 0.0, 720.0, 0.0), "scale: {:?}", describe());
assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe());
assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe());
assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe());
assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}");
assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running");
// U: a wrapper's change is one undoable step (the report layer is the other).
run(&mut host, "undo", r#"
u = doc.create_entity('Circle', center=P(1000, 0), radius=1)
step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
"#);
assert!(report(host.document()).1.is_empty());
let moved = |document: &CadDocument, x: f64| document.entities().any(|e| matches!(e, EntityType::Circle(c) if near(c.center.x, x) && near(c.center.y, 0.0)));
assert!(moved(host.document(), 1007.0));
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(2);
assert!(moved(&app.tabs[0].scene.document, 1000.0), "undo put the circle back");
assert!(!moved(&app.tabs[0].scene.document, 1007.0));
}
/// Exploration harness (not a gate): prints how the real commands answer
/// each step so the wrappers are written against observed prompts.
#[test]
#[ignore]
#[allow(unused_must_use)]
fn spike_command_runner_prompts() {
use ocs_plugin_api::host::CommandRequest as R;
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let show = |label: &str, r: &Result<ocs_plugin_api::host::CommandOutcome, String>| match r {
Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}",
o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error),
Err(e) => eprintln!("SPIKE {label}: ERR {e}"),
};
let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out };
let mk = |host: &mut HostSession<'_>, lines: &[&str]| -> Vec<Handle> {
for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); }
host.document().entities().map(|e| e.common().handle).collect()
};
let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); };
let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 5,-5 5,5", "LINE 20,0 30,0", "LINE 0,10 10,10", "LINE 0,20 10,20"]);
// EXTEND: extend line 3 toward line 2? use boundary line 2 at x=5; extend line hs[0]'s end at x=10? Extend hs[2] (20..30) leftwards to x=5 no; use hs[0] end to the wall at x=15
let _ = mk(&mut host, &["LINE 15,-5 15,5"]);
let wall = *host.document().entities().last().map(|e| &e.common().handle).unwrap();
run(&mut host, "EXTEND start", R::Start { name: "EXTEND".into() });
run(&mut host, "EXTEND pick", R::Entity { handle: hs[0], point: [9.0, 0.0, 0.0] });
run(&mut host, "EXTEND enter", R::Enter);
cancel(&mut host);
// FILLET
run(&mut host, "FILLET start", R::Start { name: "FILLET".into() });
run(&mut host, "FILLET R", R::Token { text: "R".into() });
run(&mut host, "FILLET radius", R::Text { text: "2".into() });
run(&mut host, "FILLET first", R::Entity { handle: hs[3], point: [2.0, 10.0, 0.0] });
run(&mut host, "FILLET second", R::Entity { handle: hs[4], point: [2.0, 20.0, 0.0] });
cancel(&mut host);
let _ = wall;
// CHAMFER
run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() });
cancel(&mut host);
// selection commands
host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]);
run(&mut host, "COPY run", R::Run { line: "COPY 0,0 0,3".into() });
run(&mut host, "COPY leftover", R::Enter);
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]);
run(&mut host, "ROTATE run", R::Run { line: "ROTATE 0,0 90".into() });
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]);
run(&mut host, "SCALE run", R::Run { line: "SCALE 0,0 2".into() });
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]);
run(&mut host, "MIRROR start", R::Start { name: "MIRROR".into() });
run(&mut host, "MIRROR sel", R::Selection);
run(&mut host, "MIRROR p1", R::Point { point: [0.0, 0.0, 0.0] });
run(&mut host, "MIRROR p2", R::Point { point: [1.0, 0.0, 0.0] });
run(&mut host, "MIRROR erase?", R::Token { text: "N".into() });
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]);
run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() });
run(&mut host, "ARRAYRECT sel", R::Selection);
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]);
run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() });
run(&mut host, "ARRAYPOLAR sel", R::Selection);
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]);
run(&mut host, "ERASE run", R::Run { line: "ERASE".into() });
cancel(&mut host);
host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]);
run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() });
cancel(&mut host);
eprintln!("SPIKE final entity count {}", host.document().entities().count());
}
#[test]
fn audit_python_raster_image_definition_linkage_over_real_ipc() {
use acadrust::objects::ObjectType;